Compare commits

...

508 Commits

Author SHA1 Message Date
gamer147
1960e28298 refactor(auth): decouple Steam handler from request DTO shape
Translation middleware now extracts viewer_id/steam_id/steam_session_ticket
from the decrypted msgpack dict into HttpContext.Items before the typed
DTO deserialize. The Steam handler reads from there instead of re-parsing
Request.Body — so authed action DTOs no longer need to inherit BaseRequest
to keep the auth fields alive through the msgpack→DTO→JSON pivot.

Retires the recurring footgun documented in
docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md
(2026-05-25 basic-puzzle, 2026-05-28 deck-code, 2026-06-02 Phase 3 Bot,
2026-06-10 profile/index + item_acquire_history/info + user_mypage/update).

Pinned by AuthDecouplingTests — posts an encrypted msgpack body to
/profile/index (DTO does not inherit BaseRequest) through the real
translation middleware + auth handler and asserts 200. Adds an
EncryptedMsgpackHelper + useRealAuthHandler factory flag, reusable for
future wire-shape tests.

ProfileIndexRequest, ItemAcquireHistoryInfoRequest, and
UserMyPageUpdateRequest revert to the naked shape — the per-DTO
workarounds become vestigial under the new architecture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 12:29:10 -04:00
gamer147
b18b24b085 test(ranking): literal-prod-JSON wire-shape parity
Per feedback_wire_shape_tests: controller round-trip tests using the
same DTO can't catch wire-key/wire-type drift. Asserts parsing of and
snake_case emission for the period list + monthly ranking shapes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 10:48:51 -04:00
gamer147
f743b27696 feat(ranking): stub /ranking/* (6 endpoints)
Rankings menu opens. Period picker shows deterministic monthly schedule.
Every leaderboard returns { period, ranking: [] }.

Endpoints:
- /ranking/get_viewable_ranking_period_list
- /ranking/master_point_{rotation,unlimited}_info
- /ranking/rank_match_class_win_{rotation,unlimited}_info
- /ranking/two_pick_win_info

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 10:46:00 -04:00
gamer147
80f249f8a2 feat(ranking): add RankingPeriodSchedule helper
Pure deterministic monthly period generator for the four ranking
families. Anchor dates derived from prod capture (2026-06-09): id=1 is
each family's launch month in JST; id=N is anchor + N-1 months. Used
by /ranking/get_viewable_ranking_period_list to render the period
picker and by per-family leaderboard endpoints to echo the requested
period back.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 10:36:51 -04:00
gamer147
b4aa07577f fix(friend): add BaseRequest body param to 6 body-less actions
ShadowverseTranslationMiddleware throws InvalidOperationException when
a Unity client posts an encrypted msgpack body to an action with zero
[FromBody] parameters — it has no target type for the deserializer.
Tests pass because they post JSON directly with no UnityPlayer UA and
the middleware short-circuits. Same defect already fixed on /replay/info
in 216dcab; this catches up the friend system shipped 2026-06-09.

Fixed actions: info, receive_apply_info, send_apply_info,
played_together_info, reject_apply_all, cancel_apply_all.

Tests updated to post the BaseRequest auth fields so [ApiController]
model validation passes (BaseRequest.ViewerId is non-nullable string).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:54:50 -04:00
gamer147
b54a47f333 test(replay): literal-prod-JSON wire-shape parity for /replay/info
Per feedback_wire_shape_tests (2026-05-28): controller round-trip tests
using the same DTO can't catch wire-key/wire-type drift. Asserts
parsing of, snake_case emission for, and MessagePack round-trip
through the captured prod frame.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:24:24 -04:00
gamer147
0996074287 feat(replay): wire arena two-pick finish hook
Same pattern as rank-battle: DoMatching stashes context; Finish takes
it and records history + played-together. Opponent identity is left
as placeholder fields until the resolver carries it through.

Test seeds an active ViewerArenaTwoPickRun so RecordBattleResultAsync
does not throw no_active_run during the e2e flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:22:19 -04:00
gamer147
81aac701f4 feat(replay): wire finish hook for rank-battle family
Finish now consumes the stashed BattleContext, records a
ViewerBattleHistory row (idempotent + retention-capped), and
calls IPlayedTogetherWriter for human PvP (skipped for AI).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:15:12 -04:00
gamer147
a81289311f feat(replay): stash battle context at /ai_*/start time
AiStartInternal now writes a BattleContext keyed by viewer id; the next
commit consumes it in /finish to write a ViewerBattleHistory row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:10:01 -04:00
gamer147
b44354315a fix(replay-tests): supply BaseRequest auth fields in PostAsJsonAsync bodies
Following the 216dcab fix that added [FromBody] BaseRequest _ to the
Info action, the existing tests' empty new {} payloads no longer satisfy
[ApiController] model validation (BaseRequest.ViewerId is non-nullable
string). Use the same EmptyBody() shape as RankBattleControllerTests
to mirror the production wire.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:06:36 -04:00
gamer147
216dcab316 fix(replay): add BaseRequest body param to /replay/info
The translation middleware throws InvalidOperationException when an
action has no body parameters and a Unity client sends an encrypted
msgpack body. Tests pass without it because they post JSON directly
and bypass the middleware (no UnityPlayer User-Agent).

See RankBattleController.ForceFinish for the documented convention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 08:03:37 -04:00
gamer147
c6259e5a14 feat(replay): add ReplayController for /replay/{info,detail}
/replay/info reads from ReplayHistoryReader, newest-first, capped at 50.
/replay/detail returns 400 with result_code=99 - local cache is the
canonical playback source so this endpoint is cache-miss fallback only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:59:51 -04:00
gamer147
2f7a2305da feat(replay): add /replay/info and /replay/detail DTOs
All numeric fields on ReplayInfoItemDto ship as string — matches prod
capture (frame 96 of traffic_prod_misc_clicking.ndjson). api-spec doc
will be updated separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:51:00 -04:00
gamer147
86d86f6ead feat(replay): add ReplayHistoryReader for newest-first list query
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:48:18 -04:00
gamer147
2b6c7bd6a4 feat(replay): add BattleHistoryWriter with 50-row per-viewer retention
Idempotent on (ViewerId, BattleId); evicts oldest CreateTime row when
at cap. No-op when ctx is null (server-restart safety).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:43:50 -04:00
gamer147
869f9ce13d feat(replay): add BattleContextStore for start->finish handoff
Bridges the start-time -> finish-time gap. /finish carries neither
battle_id nor opponent identity; this store holds both for the finish
handler to compose a ViewerBattleHistory row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:38:37 -04:00
gamer147
0bb0f46abc feat(replay): add ViewerBattleHistory entity + migration
New table backs /replay/info; composite PK (ViewerId, BattleId), index on
(ViewerId, CreateTime) for the newest-first list query. 50-row per-viewer
retention enforced by BattleHistoryWriter (next commit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 07:32:05 -04:00
gamer147
2d65fcd91c test(friend): end-to-end multi-viewer apply→approve→friendship flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:14:45 -04:00
gamer147
d848f4a03f test(friend): literal wire-shape round-trip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:13:16 -04:00
gamer147
2d13f0b72d feat(friend): FriendController with 12 endpoints + integration tests 2026-06-09 22:12:00 -04:00
gamer147
a6e5c9f0bc feat(friend): wire DTOs for /friend/* endpoints
12 files: 3 entry types (FriendEntryDto, FriendApplyEntryDto,
PlayedTogetherEntryDto), 5 response wrappers, 4 request DTOs.
All carry [MessagePackObject] + [Key] + [JsonPropertyName] per convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:06:36 -04:00
gamer147
b5b4781693 feat(friend): bulk apply ops + IPlayedTogetherWriter with retention cap
Implements RejectAllAppliesAsync, CancelAllAppliesAsync (ExecuteDelete bulk
deletes on incoming/outgoing applies respectively) and RecordAsync (upsert
played-together row with 50-row per-viewer retention eviction). 4 new tests
added; all 1186 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:04:04 -04:00
gamer147
17591a6ebd feat(friend): implement single-target write methods on FriendService
SendApplyAsync, ApproveApplyAsync, RejectApplyAsync, CancelApplyAsync,
RejectFriendAsync all implemented. 11 new tests appended; all 23 friend
tests pass, full suite 1182/1182 green.
2026-06-09 21:59:32 -04:00
gamer147
d078f275f8 feat(friend): implement 5 read methods on FriendService + register DI + read test suite
GetFriendsAsync, GetReceiveAppliesAsync, GetSendAppliesAsync, GetPlayedTogetherAsync,
SearchAsync all implemented. LoadViewerProjectionAsync materialises the full Viewer
entity (with Include/ThenInclude for SelectedEmblem/Degree) then projects in-memory —
avoids the EF Core limitation where Include is silently ignored under Select.
FriendService + IPlayedTogetherWriter registered as Scoped in Program.cs.
12 read tests, all green; full suite 1171/1171 still passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:53:10 -04:00
gamer147
300eee36e9 feat(friend): IFriendService + IPlayedTogetherWriter + DTO records
Task 3: service contract (interface + DTOs) and FriendService skeleton.
All methods throw NotImplementedException; Tasks 4-6 fill in the logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:44:51 -04:00
gamer147
f40ecb8ca7 feat(friend): migration for friend system tables
Generates AddFriendSystemTables migration: ViewerFriends (composite PK,
2 FK cascades), ViewerFriendApplies (int identity PK, unique on
From/To, 2 FK cascades), ViewerPlayedTogethers (composite PK, Owner FK
only). Includes composite/unique indexes. 4/4 FriendPersistenceTests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:42:41 -04:00
gamer147
1813217c16 feat(friend): add ViewerFriend + ViewerFriendApply + ViewerPlayedTogether entities
Lays the persistence foundation for the /friend/* API surface. Three new
model classes with composite PKs / unique constraints / FK cascades registered
on SVSimDbContext; 4/4 persistence tests pass on SQLite in-memory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 21:40:08 -04:00
gamer147
c1eec9057a test(gift): claim Card/Sleeve presents; reject unsupported types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 20:58:53 -04:00
gamer147
742058403c refactor(campaign): delegate gift-reward-type check to GiftRewardTypes
Delete local IsSupportedGiftRewardType and replace its single call site with
GiftRewardTypes.IsSupported — Card (5) and Sleeve (6) are now accepted.
Update unsupported-type test sentinel from 5 (Card) to 11 (SpotCard).
Add Card and Sleeve success-path tests; full suite 1152/1152.
2026-06-09 20:52:47 -04:00
gamer147
b2a5b69423 fix(gift): wire reward_type is UserGoodsType integer, not legacy 1/4/9 encoding
Replace WireRewardTypeToUserGoodsType switch with a validating identity cast backed
by GiftRewardTypes.IsSupported. Wire type 1 is RedEther (UserGoodsType.RedEther),
not Crystal (UserGoodsType.Crystal=2); the old switch silently granted the wrong
wallet for every tutorial-completion claim. Update all 5 GiftControllerTests assertions
and 1 TutorialFlowEndToEndTests assertion to expect RedEther instead of Crystals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 20:45:49 -04:00
gamer147
366a71688d feat(gift): add GiftRewardTypes supported-set helper
Centralizes which UserGoodsType values IInventoryTransaction.GrantAsync
can handle in the gift-inbox flow; both GiftController (post-fix) and
CampaignController will consult this instead of duplicating the set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 20:39:10 -04:00
gamer147
0d32d2167b feat(campaign): CampaignController.RegisterSerialCode + DTOs + tests
Adds POST /campaign/regist_serial_code with 9 integration tests covering
success path, all disqualifier conditions (unknown/disabled/expired/future/
already-redeemed/unsupported-reward-type), 401 on missing auth, and case-
sensitivity. IsSupportedGiftRewardType uses gift-wire literals (1/4/9) not
UserGoodsType enum values, matching GiftController.WireRewardTypeToUserGoodsType.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 18:51:03 -04:00
gamer147
9f7e78691b feat(serial-code): migration for SerialCode tables
Adds AddSerialCodeTables migration: SerialCodes, SerialCodeRewards,
ViewerSerialCodeRedemptions with FKs, cascade deletes, unique index
on Code, and composite index on (SerialCodeId, Slot).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:45:18 -04:00
gamer147
206be77a86 feat(serial-code): add SerialCode + SerialCodeReward + ViewerSerialCodeRedemption entities
Three new EF entities for /campaign/regist_serial_code: SerialCodeEntry (code, message,
window, enabled flag), SerialCodeRewardEntry (FK child, per-slot reward), and
ViewerSerialCodeRedemption (composite-PK redemption record). Registered in SVSimDbContext
with unique index on Code and cascade FK constraints. 3/3 persistence tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:42:10 -04:00
gamer147
b117fe825c test(profile): literal wire-shape round-trip for UserClass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:41:11 -04:00
gamer147
11215bd69f feat(profile): ProfileController + DTOs + integration tests
Add /profile/index endpoint that returns user_rank_match_total_win (stubbed 0)
and user_class_list built from viewer Classes + owned LeaderSkins. Six NUnit
integration tests cover zero wins, all classes present, level/exp/default skin,
leader_skin_id_list population, is_random_leader_skin round-trip, and 401 on
unauthenticated access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:37:35 -04:00
gamer147
f204656f4d refactor(user-class): require owned skin list, read IsRandomLeaderSkin from data 2026-06-09 17:31:06 -04:00
gamer147
da6b448598 feat(viewer): migration for ViewerClassData.IsRandomLeaderSkin
Adds AddViewerClassDataIsRandomLeaderSkin migration: boolean column on
the ViewerClassData shadow table, nullable: false, defaultValue: false.
Also updates the model snapshot to reflect the new bool property.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:27:16 -04:00
gamer147
2f4420bf15 feat(viewer): add ViewerClassData.IsRandomLeaderSkin column
Adds the bool column (defaults false) to the [Owned] ViewerClassData
entity and a two-test persistence suite that verifies round-trip and
default-value behaviour via SQLite/EnsureCreated.
2026-06-09 17:24:32 -04:00
gamer147
b50d69d3a5 test(user-mypage): assert /user_mypage/update returns 401 without auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:04:12 -04:00
gamer147
483cc1c1e0 feat(mypage): /mypage/index reflects persisted bg selection
Wires MyPageController.Index to read Viewer.MyPageBgId/SelectType/BgRotation
instead of emitting an empty MyPageBgSetting placeholder; adds Include for
MyPageBgRotation in ViewerRepository.GetViewerByShortUdid; adds fresh-viewer
zero-defaults test and write→read round-trip test (9/9 controller tests pass).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:53:34 -04:00
gamer147
6123a64b37 test(user-mypage): explain Clear() + cover unparseable rotation entries
Add a comment above MyPageBgRotation.Clear() explaining the EF OwnsMany
delete-on-clear semantics. Add a 7th test covering garbage/"" entries in
mypage_id_list falling back to BgId=0 while adjacent valid entries are
unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:50:38 -04:00
gamer147
9ff6c70faf feat(user-mypage): UserMyPageController + DTOs + persistence tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:46:49 -04:00
gamer147
b447f5032d fix(mypage): wire mypage_id/select_type/mypage_id_list as strings
Prod capture (traffic_prod_misc_clicking.ndjson) shows all three
MyPageBgSetting fields arrive as decimal strings, not ints.  Update the
DTO from int/int/List<int> to string/string/List<string> with "0"/empty
defaults, and add a literal wire-shape round-trip test pinning the
exact JSON against the capture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:40:38 -04:00
gamer147
51ef460d39 feat(viewer): migration for mypage bg selection
Adds AddViewerMyPageBgSelection migration: two int scalars on Viewers
(MyPageBgId, MyPageBgSelectType default 0) and ViewerMyPageBgRotation
owned table with composite PK (ViewerId, Slot), FK cascade to Viewers.
Also adds ToTable(ViewerMyPageBgRotation) to OwnsMany config so EF
uses the correct table name instead of defaulting to the entity class.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:33:12 -04:00
gamer147
ee808a60a2 feat(viewer): add MyPageBgSelectType + MyPageBgId scalars + MyPageBgRotation owned collection
Adds BGType persistence (0=Deck/1=CustomBG/2=RandomBG) to Viewer via two scalar
columns and an owned collection keyed (ViewerId, Slot). Two persistence tests
confirm round-trip and zero-defaults on fresh viewers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:26:48 -04:00
gamer147
8de78ba7ed test(inventory): lock Unknown-source fallthrough behaviour
Adds Commit_writes_history_row_with_Unknown_source_when_caller_omits_Source
to InventoryHistoryTests — asserts that BeginAsync without a configure callback
writes AcquireType=0 / Message="Unknown", per spec §10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:33:04 -04:00
gamer147
5334263793 feat(inventory): tag remaining BeginAsync call sites for acquire history
Add GrantSource.CardCraft (16) for card crafting via red ether, and tag
CardInventoryRepository.Create accordingly. LoadController backfill and
ArenaTwoPickService.Entry are debit/infrastructure-only — left as Unknown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:09:43 -04:00
gamer147
c2c3abc6f0 feat(gift): tag receive-gift tx as AdminGrant for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:08:27 -04:00
gamer147
6ec6a9c3fc feat(spot-card): tag exchange tx as GachaPointExchange for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:08:03 -04:00
gamer147
d759465cf2 feat(achievement): tag receive-reward tx as AchievementReward for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:07:42 -04:00
gamer147
349d7f32cd feat(leader-skin): tag buy tx as LeaderSkinBuy for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:07:19 -04:00
gamer147
30cb4727f6 feat(sleeve): tag buy tx as SleeveBuy for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:06:47 -04:00
gamer147
38a21e5e72 feat(build-deck): tag buy tx as BuildDeckBuy for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:06:23 -04:00
gamer147
5cf3cf70e8 feat(item-purchase): tag purchase tx as ItemPurchase for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:05:59 -04:00
gamer147
259e3ebe29 feat(2pick): tag finish-reward tx as ArenaTwoPickFinish for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:05:37 -04:00
gamer147
c72b692560 feat(battle-pass): tag claim tx as BattlePassClaim for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:05:09 -04:00
gamer147
d767944a83 feat(story): tag finish-reward tx as StoryFinish for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:04:11 -04:00
gamer147
21ee113d21 feat(puzzle): tag inventory tx as PuzzleReward for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:03:45 -04:00
gamer147
4aa1367b6f feat(pack): tag gacha-point exchange tx for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:03:21 -04:00
gamer147
f1d96ff554 feat(pack): tag inventory tx as PackOpen for acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:02:56 -04:00
gamer147
9130d6de11 test(inventory): pack-open shape produces acquire history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:59:39 -04:00
gamer147
d01294ebb4 test(item-acquire-history): literal wire-shape round-trip 2026-06-09 14:57:18 -04:00
gamer147
37d89aa602 refactor(inventory): share retention cap + invariant-culture date format
Introduce InventoryHistoryConfig.RetentionRowsPerViewer as the single
source of truth for the 300-row audit-log cap; InventoryTransaction
aliases it and ItemAcquireHistoryController.Take() references it
directly so the two sites cannot drift. Also adds CultureInfo.InvariantCulture
to the AcquireTime.ToString() call, matching every other WireDateFormat
site in the codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:55:43 -04:00
gamer147
f9a971a546 feat(item-acquire-history): controller + DTOs
Add ItemAcquireHistoryController (POST /item_acquire_history/info) with its
three DTOs and two integration tests (ordering + empty-viewer). The endpoint
reads ViewerAcquireHistory rows written by InventoryTransaction.CommitAsync,
ordered newest-first, capped at 300. Tests access doc.RootElement.histories
directly (no envelope wrapper in the test path — middleware skips non-UnityPlayer UA).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:49:43 -04:00
gamer147
00fbf1a185 docs(inventory): explain two-phase prune query (SQLite constraint)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:44:57 -04:00
gamer147
77ad614258 feat(inventory): prune acquire history above 300-row cap
Adds PruneAcquireHistoryAsync to InventoryTransaction.CommitAsync; runs
inside the open DB transaction after history rows are flushed, keeping at
most 300 rows per viewer (oldest discarded). Adds a covering test that
verifies the cap and per-viewer isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:41:04 -04:00
gamer147
fb1e6829b7 refactor(inventory): consolidate IsCurrency, skip num=0 grants in history
- Drop IsWalletCurrency (duplicate of IsCurrency); use IsCurrency in WriteAcquireHistory.
- Add comment on first SaveChangesAsync in CommitAsync explaining the two-phase flush.
- Guard WriteAcquireHistory loop with grant.Num == 0 check so synthetic DebitItem post-state ops do not produce history rows.
- Add InventoryHistoryTests.Commit_writes_no_history_row_for_item_debit to lock in the fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:37:50 -04:00
gamer147
bea5a1efd4 feat(inventory): write acquire history rows on commit
CommitAsync now calls WriteAcquireHistory() between the two SaveChanges
calls: iterates _ops, skips SpendOps, writes one ViewerAcquireHistoryEntry
per GrantOp. Cascade rows get GrantSource.CardCosmeticCascade; wallet
currencies zero RewardDetailId; all rows in a single commit share one
DateTime.UtcNow timestamp. Closes _source plumbing from Task 5.

5 new tests added (46 total inventory, 0 failures).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:32:51 -04:00
gamer147
015c7ce259 feat(inventory): thread GrantSource into InventoryTransaction
Add _source field + ctor parameter (between freeplay and log) to
InventoryTransaction; pass loadCfg.Source from InventoryService.BeginAsync.
Field is captured but not yet consumed — Task 6 wires it into history rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:28:05 -04:00
gamer147
f394529c8c feat(inventory): migration for viewer_acquire_history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:24:36 -04:00
gamer147
51595b0c7d refactor(inventory): drop redundant index name + IsRequired on ViewerAcquireHistory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:22:53 -04:00
gamer147
0d036e1bff feat(inventory): add ViewerAcquireHistoryEntry entity + DbSet
Adds the ViewerAcquireHistoryEntry model (8 fields: Id, ViewerId,
RewardType, RewardDetailId, RewardCount, AcquireType, Message,
AcquireTime), registers DbSet<ViewerAcquireHistoryEntry> on
SVSimDbContext, configures model (PK, FK cascade to Viewer, MaxLength
64 on Message, composite index on ViewerId/AcquireTime/Id), and adds a
DbSet round-trip integration test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:19:00 -04:00
gamer147
82dc877639 feat(inventory): add Source to InventoryLoadConfig
Adds a `GrantSource Source { get; set; }` property (defaults to
`GrantSource.Unknown`) to `InventoryLoadConfig`. Plumbing-only — no
behavior change; callers that don't set `Source` get Unknown rows,
greppable via `acquire_type=0` in dev.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:13:56 -04:00
gamer147
645c32e11c docs(inventory): annotate GrantSource gap + For() exception
Add comment above AdminGrant = 99 explaining the intentional 16–98 gap,
and add <exception> XML doc on GrantSourceMessages.For() so IDE tooling
surfaces the ArgumentOutOfRangeException.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:11:45 -04:00
gamer147
9f65326449 feat(inventory): add GrantSource enum + message lookup
Introduces GrantSource (17 values, DB-persisted) and GrantSourceMessages.For()
for the item-acquire-history feature. Values 1/2 mirror prod captures;
coverage test verifies every enum value has a non-empty message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 14:08:01 -04:00
gamer147
998402ebc3 refactor(tutorial-presents): promote static catalogue to seed-driven TutorialPresentEntries table
The five tutorial gifts every fresh viewer is given at signup used to live as a
static C# array in SVSim.Database/SeedData/TutorialPresents.cs — outside the
seed-JSON pipeline used by all 40+ other globals tables. Editing a gift required
a code change + rebuild instead of a JSON edit + bootstrap re-run.

Now authored in SVSim.Bootstrap/Data/seeds/tutorial-presents.json and loaded into
a new TutorialPresentEntries table via TutorialPresentsImporter (clear-and-rewrite,
mirroring HomeDialogs). ViewerRepository.RegisterAnonymousViewer reads the table
at signup and projects each row into a ViewerPresent with Source="tutorial".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 09:53:10 -04:00
gamer147
7118b92522 refactor(pack): type PackChildGachaEntry.TypeDetail as CardPackType enum 2026-06-09 08:48:16 -04:00
gamer147
833bd85d36 data(seed): regenerate packs.json including free-pack children from event-crate capture 2026-06-08 21:51:05 -04:00
gamer147
57d231cd56 feat(pack): /pack/open supports type_detail=10 FREE_PACKS with per-campaign daily quota 2026-06-08 21:43:04 -04:00
gamer147
6c7e8ae8ad feat(pack): /pack/info filters spent free-pack children and emits campaign metadata 2026-06-08 21:38:26 -04:00
gamer147
b9c29b53d9 feat(pack): add free-pack metadata fields to PackChildGachaDto 2026-06-08 21:34:49 -04:00
gamer147
7d7cf699f8 feat(viewer): add FreePackClaims owned collection for daily-free quota tracking 2026-06-08 21:34:20 -04:00
gamer147
d762c5766f feat(pack): persist daily_free_gacha_count on PackChildGachaEntry 2026-06-08 21:31:02 -04:00
gamer147
7e4a9654b2 feat(seed): add DailyFreeGachaCount to PackChildGachaSeed 2026-06-08 21:23:14 -04:00
gamer147
feee6e7c09 test(tutorial-e2e): seed tutorial presents — RegisterViewer no longer auto-seeds 2026-06-08 20:51:42 -04:00
gamer147
83e89455e2 test(signup): assert tutorial presents seeded by RegisterAnonymousViewer 2026-06-08 20:46:27 -04:00
gamer147
7a582f310e test(gift): prod-URL coverage and state=3 delete behavior 2026-06-08 20:45:49 -04:00
gamer147
f1d881b26a fix(gift): drop RowVersion (SQLite incompatible) + restore wire reward_type map
[Timestamp] byte[] doesn't work under SQLite (the test backend) — EF
expects the DB to populate it on insert, but SQLite has no equivalent
of Postgres's xmin. The WHERE Status = Unclaimed filter plus
IInventoryService's viewer-level concurrency is the practical defense;
RowVersion was only a backstop. Regenerated the migration without the
RowVersion column.

Wire reward_type on the gift endpoint uses a gift-specific scheme that
diverges from UserGoodsType for currencies: wire 1 = Crystal (enum=2),
wire 9 = Rupy (enum=9), wire 4 = Item (enum=4). A naked cast resolves
wire 1 to UserGoodsType.RedEther and silently grants the wrong wallet
— restored the explicit WireRewardTypeToUserGoodsType map from the old
tutorial controller.

Retrofits existing GiftControllerTests to call SeedTutorialPresentsAsync
on the new helper (RegisterViewer doesn't auto-seed; only the prod
signup path does). All 7 existing tests pass.
2026-06-08 20:44:52 -04:00
gamer147
ca36792be3 feat(db): migration — add viewer_presents, drop viewer_claimed_tutorial_gifts 2026-06-08 20:38:44 -04:00
gamer147
6098682162 refactor(db): remove ViewerClaimedTutorialGift — replaced by ViewerPresent.Status 2026-06-08 20:38:09 -04:00
gamer147
fafd7ea183 test(gift): add SeedTutorialPresentsAsync helper 2026-06-08 20:37:26 -04:00
gamer147
2b35ae0890 feat(gift): unified GiftController over ViewerPresent + route aliases 2026-06-08 20:36:44 -04:00
gamer147
c1d7cd2441 feat(dtos): add PresentMapper.ToWire(ViewerPresent) 2026-06-08 20:35:59 -04:00
gamer147
bf51dabcff refactor(dtos): promote PresentDto to Common/ 2026-06-08 20:35:42 -04:00
gamer147
2ce399ff87 feat(signup): seed tutorial gifts as ViewerPresent rows on /tool/signup 2026-06-08 20:34:48 -04:00
gamer147
f991ef762f feat(db): add TutorialPresents seed list 2026-06-08 20:34:11 -04:00
gamer147
eea596c6ec feat(db): wire ViewerPresent into SVSimDbContext with indexes 2026-06-08 20:33:50 -04:00
gamer147
a6a8c6b1a4 feat(db): add ViewerPresent entity for unified gift inbox 2026-06-08 20:33:10 -04:00
gamer147
ce32a9c6b7 feat(home-dialog): seed file + importer + bootstrap hookup
Mirrors banners pattern: clear-and-rewrite from per-table JSON seed.
Ships one entry pointing at parent_gacha_id 80032 to match the
2026-06-03 prod capture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:57:38 -04:00
gamer147
9d6a5cc3b9 feat(home-dialog): populate home_dialog_list on /mypage/index
Walk-down behavior: each call emits the highest-priority unfired
active dialog; subsequent calls walk to the next-priority entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:55:48 -04:00
gamer147
7e757ebcd2 feat(home-dialog): per-session suppression tracker
Singleton keyed by ShortUdid; lock on per-viewer set to avoid
cross-viewer contention. Process lifetime — restart re-fires.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:53:33 -04:00
gamer147
6d60edaa2a feat(home-dialog): IGlobalsRepository.GetActiveHomeDialogsAsync
Window is [begin, end) — exclusive upper bound. Ordered priority-DESC
then Id-ASC so the controller can break on the first match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:52:32 -04:00
gamer147
7a82f4e189 feat(home-dialog): add HomeDialogEntry entity + migration
DDL-only per migrations-are-ddl-only convention. Seeded by
SVSim.Bootstrap MyPageGlobalsImporter (T5) — no HasData.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:51:12 -04:00
gamer147
d3488c3bc6 fix(viewer): default ClassExp.Level to 1 for new viewers
Client RankMatchUI.onOpen indexes _classCharaExpList[level - 1]
unconditionally; level 0 (the prior default) throws IOOR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 18:49:45 -04:00
gamer147
20ddba4c5f Merge battle-engine-extraction: engine port + multi-instancing
Lands the Phase 1 + Phase 2 + multi-instancing migration as one mergepoint.

Phase 1 — engine extraction (M1-M14): client's BattleManagerBase ported
headless under SVSim.BattleEngine; deterministic emit; authoritative RNG
seam (Q-RNG/F2); single-card resolution set proven.

Phase 2 — headless conductor (M-HC-0..4 + M-HC-exit): receive conductor
runs headless inside the battle node via no-op view shims + InstantVfx
rule (zero Engine/*.cs edits, check_drift.py clean). Six structural
shadow-vs-wire divergences resolved against live PvP traffic.

Multi-instancing: per-battle engine statics now AsyncLocal-scoped via
BattleAmbientContext (Mgr, GameMgr, ViewerId, IsForecast, IsRandomDraw,
RealTimeNetworkAgent, BattleRecoveryInfo). EngineSessionGate deleted;
SessionBattleEngine wraps all entry points; MultiInstanceEngineTests is
the regression oracle.

Parallelism hardening: Resources._loaded + GameObject._components shim
Dictionaries -> ConcurrentDictionary; Wizard.LocalLog mutations gated by
single static lock. SVSim.BattleEngine.Tests now runs under
ParallelScope.Fixtures; StressN parallelizes Setup AND Drive.

Pending: live two-pair PvP smoke (Task 10 of the multi-instancing plan).

Suite: SVSim.UnitTests 1054/1054; SVSim.BattleEngine.Tests 59/2 (skips
pre-existing). Audit script tools/engine-port/audit-static-writes.ps1
green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 10:21:09 -04:00
gamer147
5a23f93152 docs(engine-ambient): explain why _components GetOrAdd factory is contention-safe
Reviewer noted the factory may be invoked more than once under contention.
Document the analysis inline so a future reader doesn't have to redo it:
the discarded instance's mutations land on private fields of a soon-unreachable
object, and the only shared sentinel (_noopViewMaterial) is read-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 08:25:34 -04:00
gamer147
fbac66fd0b chore(engine-ambient): harden shim + LocalLog statics for fixture parallelism
Follow-up to the multi-instancing migration. Wraps the process-shared engine
statics that aren't ambient-fronted but race between concurrent battles:

- UnityEngine.Resources._loaded: Dictionary -> ConcurrentDictionary.GetOrAdd
  (the shared prefab cache keyed by path; concurrent first-misses produced
  duplicate GameObjects + Dictionary corruption)
- UnityEngine.GameObject._components: Dictionary -> ConcurrentDictionary with
  Interlocked.CompareExchange init (Resources.Load returns SHARED prefab
  GameObjects, so two engines' Setup() can race on the same _components map
  — surfaced as "Operations that change non-concurrent collections" crashes
  during BattleManagerBase ctor's GetComponent<T>() chain)
- Wizard.LocalLog: single static lock around all mutating entry points
  (StringBuilder _lastTraceLogStringBuilder + ~12 mutable string/bool/int
  scratch fields; serializing the trace-log surface is cheap since logging
  is not the hot path)

Flips SVSim.BattleEngine.Tests assembly Parallelizable scope from Self to
Fixtures and restructures MultiInstanceEngineTests.StressN_BaselineMatches so
Setup runs INSIDE Task.Run (was previously serialized as a workaround for the
LocalLog races). The fixture is also lifted to ParallelScope.All so the
two-engines and stress tests can run alongside each other.

Suite fully green under fixture parallelism (59/0/2 across 3 consecutive runs);
SVSim.UnitTests still 1054/0/0 — true multi-instance correctness is now proved
end-to-end in tests rather than gated behind a serial workaround.

Manifest sha refresh + new patch artifact for the LocalLog edit (decomp-origin);
the two shim files are authored, so no metadata update is needed for them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 08:02:49 -04:00
gamer147
45344e6d83 chore(engine-ambient): audit script for static-write regressions
Step 9 of multi-instancing migration. PowerShell audit fails CI if anything
references the deleted BattleManagerBase.main field or introduces a new
Thread() outside the LeanThreadPool allowlist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 23:31:09 -04:00
gamer147
ab4545b274 test(engine-ambient): tighten MultiInstanceEngineTests post-setup assertions
Replace trivially-true Pp>=0 with concrete post-Setup pins (LeaderLife=20,
Pp=0, HandCount=3). Drop the unused seed parameter from SampleDeck - every
call already returned the same vanilla deck, and the StressN test name 'Random
Decks' overpromised. The cross-contamination property the test pins (parallel
LeaderLife[] equals sequential LeaderLife[]) holds with identical decks +
distinct masterSeeds, which is what's actually being verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 23:28:21 -04:00
gamer147
c789d836f1 feat(engine-ambient): delete static fallbacks; add MultiInstanceEngineTests
Step 8 (final) of multi-instancing migration. All per-battle statics now
require a BattleAmbient scope — unwrapped writes throw InvalidOperationException
(fail-fast forcing function). MultiInstanceEngineTests proves correctness:
two parallel battles resolve independently, N=4/8/16 stress matches sequential
baseline, GameMgr.GetIns throws without scope.

Migration complete. EngineSessionGate gone. Suite fully green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 23:19:37 -04:00
gamer147
9e93a7b198 refactor(engine-ambient): wrap residual UnitTests + delete EngineSessionGate
Step 7 of multi-instancing migration. Residual SVSim.UnitTests that touch
engine code directly are wrapped in TestBattleScope. EngineSessionGate is
deleted along with the _engineOwned bookkeeping in BattleSession; engine
setup is unconditional now that per-battle state is isolated on the ambient.
Gate-specific fallback branches in BattleSession.ShadowIngest are simplified.

Suite fully green (SVSim.UnitTests, SVSim.BattleEngine.Tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 22:43:18 -04:00
gamer147
8af1be6555 test(engine-ambient): TestBattleScope + HeadlessFixture split for multi-instance
Step 6 of multi-instancing migration. HeadlessEngineEnv.EnsureInitialized
is split into EnsureProcessGlobals (idempotent, process-once) +
SeedCharaIdsOnCurrentAmbient (per-test). New TestBattleScope IDisposable
sets up a fresh BattleAmbientContext per test. NonParallelizable removed
from converted classes; assembly-level Parallelizable(Fixtures) enabled.

SVSim.BattleEngine.Tests fully green under parallel test execution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 22:24:21 -04:00
gamer147
1ba75c565a refactor(engine-ambient): GameMgr.GetIns throws Require; wrap SessionBattleEngine entry points
Step 5 of multi-instancing migration. GameMgr.GetIns() now resolves through
BattleAmbient.Require() (throws when no scope active — fail-fast since engine
callers unconditionally dereference). SessionBattleEngine now owns a single
BattleAmbientContext, pushed via BattleAmbient.Enter at Setup/Receive/all
~30 read accessors and Debug* seams.

EngineGlobalInit.WirePerSessionGameMgr extracted out of the _done-gated block:
GameMgr is now per-session (ctx.GameMgr is a fresh `new()` per SessionBattleEngine),
so the DataMgr chara ids + NetworkUserInfoData seeding must run every Setup, not
process-once. The wiring itself is already idempotent. Without this, second-or-
later sessions in a process NRE in NetworkBattleManagerBase.CreateBackgroundId.

Expected state: SVSim.BattleEngine.Tests have known-failing tests that don't
go through SessionBattleEngine (Task 6 wraps HeadlessFixture). SVSim.UnitTests
mostly recover; residual failures (deal-frame Accepted:false in conductor
integration tests) are captured in
data_dumps/task5-test-output/failing-tests-after-task5-node-postwrap.txt for
Task 7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:56:34 -04:00
gamer147
18da7fd19e test(engine-ambient): cover BattleRecoveryInfo setter ambient write-through
Setter is the asymmetric one (write-through inside scope, unlike ViewerId's
no-op-in-scope) — adding parity with the SetRealTimeNetworkBattle ambient
setter test to catch future regressions if the routing branch is touched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:42:55 -04:00
gamer147
fe146fde50 refactor(engine-ambient): ViewerId/RealTimeNetworkAgent/BattleRecoveryInfo read ambient first
Step 4 of multi-instancing migration. Three additional per-battle statics
front-fronted by BattleAmbient.Current, each with a static fallback for
unwrapped callers. ViewerId's SavedataManager-persisting setter is preserved
on the fallback path; inside a scope, the setter is a no-op (the per-battle
perspective is fixed at scope entry).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:37:58 -04:00
gamer147
4e756a6c46 refactor(engine-ambient): BattleManagerBase.GetIns reads ambient first, static fallback
Step 3 of multi-instancing migration. The dominant per-battle singleton now
resolves through BattleAmbient.Current.Mgr when a scope is active. The legacy
'main' field is renamed _mainFallback and retained for unwrapped callers
(tests, anything not yet scope-wrapped). GetIns() still returns null when
neither is set, preserving the '?.Foo ?? default' patterns in engine code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:23:27 -04:00
gamer147
92da7819f4 chore(engine-ambient): refresh BattleManagerBase manifest sha + add patch artifact
Hygiene fixup for the IsForecast/IsRandomDraw ambient conversion in 3b5f2e1.
The manifest sha was stale (pointed at the pre-ambient RNG-virtual-patched
contents) and the change had no companion .patch artifact alongside
BattleManagerBase.rng-virtual.patch. Follow established convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:16:57 -04:00
gamer147
3b5f2e18b3 refactor(engine-ambient): IsForecast/IsRandomDraw read ambient first, static fallback
Step 2 of multi-instancing migration. Both flags now resolve through
BattleAmbient.Current when a scope is active, otherwise hit a static fallback
that preserves today's behavior unchanged for unwrapped callers.

Suite green: SVSim.BattleEngine.Tests pass; SVSim.UnitTests baseline holds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:49 -04:00
gamer147
4829e8c263 feat(engine-ambient): add BattleAmbientContext + AsyncLocal scope
Step 1 of the engine multi-instancing migration. Standalone infrastructure;
no engine static reads/writes through it yet. Scope is reentrant (restores
prior on dispose), AsyncLocal flows across awaits, and isolated between
concurrent Task.Run flows.

See docs/superpowers/specs/2026-06-07-engine-multi-instancing-design.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 21:04:21 -04:00
gamer147
addeb021d2 fix(battlenode): shadow engine tracks live PvP wire-truth (full battle, multiple bid regressions)
Six distinct fixes accumulated over live-test iterations against four bids
(654473755566, 806245601092, 283192092460, 131549100204, 799755786270) — together
they take the shadow engine from "throws on the first non-mulligan play" to
"survives a full PvP battle, only weird-edge-case Unity touches still left to whack".

1. Engine StableRandom seed aligned with clients' Matched.seed
   (BattleSession.EnsureEngineSetup, NodeNativeBattleHarness.Create). Clients seed
   _stableRandom with BattleSeeds.Stable(masterSeed) (the value the node ships in
   Matched.seed); we were passing the RAW masterSeed to engine.Setup, so every
   StableRandom call diverged from call #1 onward — every turn-1+ draw picked a
   different deck position than the clients. Verified Stable(1184631275)=1543475792
   matches the wire on bid 654473755566.

2. SeedDeck advances cardTotalNum to deck.Count+1 + pins BattleStartDeckCardList.
   Mirrors SBattleLoad.InitPlayer's tail (SBattleLoad.cs:1292). Without it,
   skill-generated tokens auto-assigned Index 0,1,... and COLLIDED with deck-loaded
   indices 1..40 — silent until something addressed the deck card with the
   colliding Index (Hoverboarder at deck idx 1 + a token at engine Index 1 made
   GetBattleCardIdx's SingleOrDefault throw on bid 806245601092).

3. BattleCardView.GameObject lazily non-null in the shim (ViewUiTouchStubs.cs).
   The IsRecovery card-create delegate (NetworkBattleManagerBase.cs:379) passes
   null cardGameObject; Skill_metamorphose.cs:147 in the in-play branch then NRE'd
   on `metamorphosedCard.BattleCardView.GameObject.transform.rotation = identity`,
   a purely cosmetic touch with no game-state implication. Bid 283192092460:
   Petrification on a board follower.

4. TranslateChoiceKeyAction unwraps wrapped selectCard on shadow ingest
   (SessionBattleEngine.cs, sibling to TranslateTargetOwners). Live sender-send
   wires Choice plays as selectCard:{cardId:[...], open:0}; engine's
   ConvertToListInt does `value as List<object>` — a Dict casts to null and
   foreach NREs. The receiver's swallow-all catch (NetworkBattleReceiver.cs:1255)
   logs to Debug.LogError + LocalLog — both shimmed/no-op'd headlessly — and
   returns false, but Receive calls ReceivedMessage with checkBreakData:false so
   the false isn't propagated. The play continues with choiceIdList=[], the chosen
   branch never resolves, the played card stays in hand; a later targeted play
   (A's bounce on B's "board" idx 20) then can't find the target → NRE on null in
   ActionProcessor.PlayCard:407. Bid 131549100204: B's Resonance + A's bounce.
   Opponent-relay path is unaffected — node strips selectCard from broadcasts.

5. HeadlessHandViewStub overrides HandUnfocus/HandFocus/FocusRearrangeHandHand
   to return NullVfx. CreateHandControl returns null in headless; the base
   methods unconditionally deref `_handControl.SetHandState(...)`. A follower
   with a when_spell_play Heal trigger fired on its leader for amount 0 — even
   a 0-heal drives ApplyHealing → CreatePullHandInVfx → HandUnfocus → NRE.
   Bid 799755786270: two consecutive spell plays both crashed this stack.
   Added InternalsVisibleTo("SVSim.BattleEngine.Tests") so the shim-level
   regression tests can pin the no-op contracts directly.

Plus the previous-session fixes carried in this same uncommitted state
(see docs/superpowers/plans/2026-06-07-shadow-engine-desync-handoff.md):
  - doesPlayerGoFirst:true + mgr.IsFirst:true (turn-1 draw count correct
    per seat)
  - RecoveryOperationCollection.PlayHandCardOperation routes all type:30
    through PlaySkillSelectHandCardOperation (skips the two-phase user-select
    guard that aborts targeted spells in recovery)
  - ShadowFeed + ToRawBody: server-generated typed bodies (DealBody, etc.)
    converted to RawBody before engine.Receive (`env.Body as RawBody`
    returned null for typed bodies)
  - Ready idxChangeSeed seeds A's XorShift via the receiver; B's seed is
    injected via SeedOppoIdxChange (BattleSeeds.IdxChange + viewerId)
  - ReadySpin defaulted to 0 (was 243) — non-zero double-cranks the shadow
    which ingests BOTH sides' Ready frames on one stream

Test counts: SVSim.UnitTests 1054/1054, SVSim.BattleEngine.Tests 34/34.

Open: known-residual Unity touches are individual whack-a-mole now (per-card
skill edge cases), not the structural divergences fixed here.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 19:05:07 -04:00
gamer147
2a8c44a6d7 build(battleengine): pin LangVersion 12.0 (was 'latest') so C# 14's 'field' keyword doesn't break the decompiled engine under newer SDKs 2026-06-07 08:03:01 -04:00
gamer147
25751462f4 fix(battlenode): translate live isSelf target frames to engine vid shape on ingest (live PvP fidelity)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:44:53 -04:00
gamer147
97e4664cc4 docs(battlenode): regen-guard banners on hand-edited .g.cs + accessor-band null-policy invariant (M-HC-4 final review) 2026-06-07 01:02:00 -04:00
gamer147
8bd8d1db2f docs(battlenode): correct EVOLUTION_SELECT deferral rationale — skill data is present (M-HC-4) 2026-06-07 00:52:22 -04:00
gamer147
f1c96ed37d refactor(battlenode): M-HC-4 cleanup — EpCount rename, dedupe evolve-ramp, drop tautological guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:47:33 -04:00
gamer147
a30a496265 refactor(battlenode): engine-first token identity (cardId); keep wire-mining fallback (M-HC-4f, partial)
Source the played card's opponent-facing knownList[].cardId off the shadow engine
(SessionBattleEngine.PlayedCardId -> BattleCardBase.CardId), engine-first with the
wire-mined idx->cardId map as the fallback. PROVEN engine-resolved (each backed by a
HeadlessConductorTests PlayedCardId_* test): deck cards and receive-path substituted/
revealed tokens (engine seats the wire id at the wire idx).

PARTIAL retirement: the wire-mining bookkeeping (MineAddOps/MineChoicePicks/MineCopyTokens
+ Record*From) is KEPT as the load-bearing fallback. The choice/Discover, copy/clone and
cross-side (isSelf:0) token cases are NOT proven to resolve at a wire idx headless — the
autonomous token_draw path seats a chosen token at engine Index 0 (would collide with the
leader), and copy/cross-side aren't cheaply fixturable. Deleting their mining on faith
would silently corrupt opponent reveals, so it stays behind a TODO(M-HC-4f) gate.

- SessionBattleEngine.PlayedCardId: new accessor mirroring PlayedCardClan/Tribe.
- BuildPlayedCard: signature deckMap->explicit cardId; null on cardId==0 (no engine id AND
  no mined/deck-map fallback).
- PlayActionsHandler: cardId = engine.PlayedCardId(seat, idx, fallback: mapped) ; mining retained.
- Tests: PlayedCardId_* (deck/substituted/degrade pass; choice-gap [Explicit] documents the
  Index-0 finding). KnownListBuilder + CaptureConformance call-sites updated to new signature.

Full BattleNode suite 263/263 green; HeadlessConductorTests 27/27; drift clean; no Engine edits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:36:49 -04:00
gamer147
d3508d7bd4 fix(battlenode): PlayedCardTribe degrades to 0 not empty; clan/tribe builder tests (M-HC-4e review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:23:07 -04:00
gamer147
693fba5003 feat(battlenode): emit engine-resolved clan/tribe on knownList entries (M-HC-4e)
Prod always emits clan (int ClanType) + tribe (comma-joined int TribeType
string, "0" for none) on every knownList entry (battle-traffic_tk2_regular
.ndjson). Source both off the resolved engine (SessionBattleEngine.PlayedCardClan/
PlayedCardTribe -> BattleCardBase.Clan/Tribe), so skill-applied clan/tribe
changes ride the wire rather than the static card-master value. Thread through
KnownListBuilder.BuildPlayedCard + PlayActionsHandler; add clan/tribe to the
KnownCardEntry DTO (always present, non-null). Node-side only; no engine edits,
drift clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:11:28 -04:00
gamer147
daaec20afb test(battlenode): board-dependent when_evolve_other cost validated headless (M-HC-4d)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:54:13 -04:00
gamer147
3285097d1b test(battlenode): target-discriminating + documented choice shape (M-HC-4c review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:46:59 -04:00
gamer147
3add58f939 feat(battlenode): target/choice ops resolve on engine state via view-untangle (M-HC-4c)
A targeted hand-play (PLAY_HAND_SELECT, opcode 31) and a choice play (PLAY_HAND +
keyAction type Choice) both resolve headless through the recovery receive conductor
with NO new shim/view fills — the 4a/4b view seeds (DetailPanelControl, _inPlayFrameEffect,
_playerInfoPair, HeadlessConductorVfxMgr) already cover the target/choice surface, because
the recovery path resolves targets/choices from the wire frame without the interactive
select UI, and the damage/token VFX execute through the existing top-level InstantVfx path.

Fixtures (cards.json, full skill mechanics):
- single-target: 100414020 (cost-1 Dragoncraft spell, when_play damage=2 to a selected
  enemy unit). Asserts the enemy 1/4 (101411060) drops to life 2 — exact magnitude, survives.
- choice: 127011010 (cost-1 Neutral choice follower, choose 1 of 2 tokens to add to hand).
  Asserts the chosen token (B) lands in hand and the un-chosen token (A) does not — decisive
  about WHICH branch resolved. Wire keyAction shape cross-checked against a real capture of
  this exact card (battle_test/rng/battle-traffic_cl1.ndjson); the receiver consumes a flat
  selectCard list (ConvertToListInt).

Drivers: NodeNativeBattleHarness.TargetedPlayBody (reuses the {targetIdx,vid,selectSkillIndex}
target shape proven by AttackBody) + ChoicePlayBody. Zero Engine/*.cs edits (drift clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:34:39 -04:00
gamer147
2e8f9ab64e feat(battlenode): evolve resolves on engine state via view-untangle (M-HC-4b)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:08:59 -04:00
gamer147
7a02cb3626 docs(battlenode): note _playerInfoPair seeded ahead for evolve (M-HC-4a review) 2026-06-06 22:59:16 -04:00
gamer147
c5a511e4fe feat(battlenode): attack resolves on engine state via view-untangle (M-HC-4a)
Drive ATTACK frames through the headless receive conductor and assert on engine
board state (node-native harness). Two cases: follower -> enemy leader (leader
life drops by atk, attacker spent) and a lethal follower-vs-follower trade (both
removed). ATTACK opcode confirmed = 10 (NetworkBattleDefine.PlayActionType).

Headless view-untangle (no Engine logic edits; drift clean):
- IBattlePlayerView.AttackSelectControl -> non-null HeadlessAttackSelectControl
  (no-op RegisterAttackPair/ResetCardAfterAttack); IsCardTranslatable left to base.
- IBattleCardView.CardInfo -> backing card via BuildInfo (so IsCardTranslatable
  reads authentic IsClass); class/null view ctors now chain : base(buildInfo).
- IBattleCardView._inPlayFrameEffect -> non-null no-op control.
- Seed Certification.viewer_id headless so the IsRecovery target parse
  (vid != UserViewerID) does not throw inside SavedataManager and silently drop
  the parsed targetList.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:48:26 -04:00
gamer147
0d7136787a refactor(battlenode): retire spellboost bookkeeping, engine owns cost+spellboost (M-HC-3)
The headless engine accumulates spell-charge for real on the receive path
(each spell play runs the played card's own AddSpellChargeCount) and resolves
the discounted cost by construction, so the wire-derived spellboost-count
bookkeeping is redundant. Engine-source the knownList spellboost COUNT too
(prod-faithful) via a new SessionBattleEngine.PlayedCardSpellboost, using the
same persist-post-play zone search as PlayedCardCost (SpellChargeCount survives
PlayCard; only ctor/ReturnCard zero it).

- Delete IdxToSpellboost/SpellboostMap/GetSpellboostMap/RecordSpellboostFrom
  (BattleSessionState) and MineAlterSpellboosts (KnownListBuilder); token/choice/
  copy identity maps are untouched.
- BuildPlayedCard takes an engine-sourced spellboost int (drops spellboostMap).
- Seed BattleLogManager fusion lists headless (the per-frame filter cleanup
  NREs on null EnemyFusionCard when a fanfare card registers a CalledCreateFilter)
  so real spell-charge grantor plays resolve.
- Add committed real-charge regression tests (no SeedHandCardSpellboostCost seam):
  one grantor play accumulates +1 on the reducer -> cost 5->4, count 1, persisting
  post-play; handler emits cost 4 + spellboost 1 engine-sourced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:48:50 -04:00
gamer147
51419d15cd feat(battlenode): emit engine-resolved cost on every knownList entry (M-HC-3)
The opponent-facing PlayActions knownList now carries the engine-RESOLVED
play-time cost (KnownCardEntry.cost), sourced from the headless shadow engine's
PlayedCost on the just-resolved card. This closes the spellboost cost-desync BY
CONSTRUCTION: the engine already knows the true discounted cost (spellboost +
board modifiers folded in), so no bookkeeping is needed.

- DTO: add non-nullable cost to KnownCardEntry (prod emits cost 45/45).
- SessionBattleEngine.PlayedCardCost(seat, idx, fallback): finds the resolved
  card by engine Index across in-play/cemetery/hand zones and returns PlayedCost
  (captured by PlayCard at resolution == discounted Cost), degrading to fallback
  when the engine is not owned/ready.
- PlayActionsHandler sources the played card's cost from ctx.Engine (ShadowIngest
  already resolved the play before the handler runs). Spellboost-map plumbing
  stays for now; Task 6 (M-HC-3b) retires it.
- Validation: engine-read test (charge-seeded reducer 101314020: base 5, cost
  5/1/0 at charge 0/4/5) + handler-emit test asserting knownList[0].cost == 1
  (discounted, not base 5) with non-vacuity. Board-dependent (when_evolve_other)
  case deferred to M-HC-4 (evolve not yet headless); cost is read off the resolved
  engine so board modifiers are captured by construction once their ops resolve.
- Harness: promote alt vanilla follower id (101211120) to AltVanillaFollowerId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:18:29 -04:00
gamer147
b73f0f7157 test(battlenode): reveal test stresses cardId substitution with mismatched seed (M-HC-2 review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:01:01 -04:00
gamer147
07ffc8906d feat(battlenode): opponent reveal resolves on engine state via ReplaceReceivedCards (M-HC-2)
Drive a node-native battle to seat B's turn, then ingest an opponent
PlayActions reveal frame (knownList[{idx,cardId,to:Field}], isPlayerSeat:false)
matching battle_test_cl2's wire shape. The engine's ReplaceReceivedCard.ReplaceCard
-> CreateActualCard -> CreateBattleCardWithGameObject path resolves headless and
seats the substituted card on seat B's board with the wire cardId. No Engine/ logic
edits and no new view shims were needed — the card-creation view surface is fully
covered by the BackGround/icon-anim/play-queue/hand stubs from Tasks 2/3.

Adds InPlayCardId(seat, boardPos) accessor (SessionBattleEngine + harness) to read a
seated in-play follower's true identity, leader-excluded like BoardCount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:51:55 -04:00
gamer147
b1d17fb97d test(battlenode): unify DealBody helper + assert seat-B deck (M-HC-1 review)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:43:04 -04:00
gamer147
f0977ab45c feat(battlenode): mulligan+turn ops track on engine state (M-HC-1)
Task 2's WireMulliganPhase already installed the full mulligan delegate
set (Swap/Ready, not just Deal) via MulliganEventSetting, and the
mulligan + turn-draw mutations flow through VfxMgr.RegisterSequentialVfx
— which HeadlessConductorVfxMgr runs for InstantVfx. So Swap/Ready/
TurnStart/TurnEnd resolve headless with ZERO new shim/seed/view fills.

Adds the M-HC-1 milestone assertions: a mulligan-swap test (post-swap
hand holds deck idx 1,2,4 — idx-3 swapped for the next unused idx) and a
two-turn test (Deal->Swap->Ready->TurnStart/TurnEnd x2) asserting the
engine's deterministic node-native progression on both seats
(hand/deck/PP/turn/leader-life) at each boundary. Frame shapes mirror the
captured battle_test_cl1 receive stream (self/oppo pos-idx lists, spin).

Harness/node: +DeckCount/Turn board-state pass-throughs (test reads).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:30:39 -04:00
gamer147
e96cc3363c refactor(battlenode): guard generated iface-impl against regen + stub visibility (M-HC-0 review)
- _IfaceImpl.g.cs: extend header to warn about hand-edits; tag all bare
  // HEADLESS-FIX lines with their milestone (M13 on GetSideLogControl ×2)
  so `grep HEADLESS-FIX` reliably surfaces every block before a regen.
- HeadlessHandViewStub / HeadlessPlayQueueViewStub: narrow from public to
  internal sealed — both stubs are consumed only within SVSim.BattleEngine
  (via the generated partial impls); no public surface exposes the concrete
  type, so internal is correct and aligns with HeadlessIconAnimations.
- SessionBattleEngine.SeedMulliganInfoControl: add one-line comment on the
  GetComponent<MulliganInfoControl>() call explaining the shim's lazy
  materialisation behaviour (otherwise reads like a guaranteed NRE).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:20:34 -04:00
gamer147
35e9847911 feat(battlenode): receive conductor resolves self Deal+Play headless via view-untangle (M-HC-0)
The engine's receive CONDUCTOR fuses each authoritative mutation behind a view
call: the play mutation is an InstantVfx registered to VfxMgr, and the deal hand
is seated by MulliganPhaseBase.StartDeal wired to OperateReceive.OnReceiveDeal.
Headless, the shared VfxMgr no-op'd registration (correct for the direct
ActionProcessor path the M2-M12 oracles use) and OnReceiveDeal was never wired,
so the receive path resolved nothing.

Untangle (Candidate B, zero Engine logic edits):
- InstantVfx.Run() opt-in executor (authored shim).
- HeadlessConductorVfxMgr : VfxMgr runs registered InstantVfx; wired only via the
  node's SessionContentsCreator.CreateVfxMgr (verified the receive mgr's VfxMgr
  comes from there — BattleManagerBase.cs:768). M2-M12 use HeadlessContentsCreator,
  so they're isolated by construction.
- WireMulliganPhase: construct NetworkMulliganPhase + MulliganEventSetting() to
  install OnReceiveDeal -> StartDeal (the node never pumps the phase machine).

View no-op surface (the 7 from the probe, minus 1 not hit; +1 emergent):
- Deal wiring (NetworkMulliganPhase) [node seed]
- MulliganInfoControl._partsPlayer/_partsOpponent._exchangeMark/_keepZone/_abandonZone [node seed: prefab + SeedMulliganInfoControl]
- Data.BattleRecoveryInfo (IsMulliganEnd=false) [EngineGlobalInit seed]
- IBattlePlayerView.PlayQueueView -> HeadlessPlayQueueViewStub [_IfaceImpl.g.cs, both getters]
- DetailMgr.DetailPanelControl/SubDetailPanelControl [node seed]
- BattleCardIconAnimations.collection (emergent: UpdateInPlayBattleCardIconLabel) -> HeadlessIconAnimations empty SkillCollectionBase [_IfaceImpl.g.cs]
- BattleMenuBtn (probe item 7): NOT hit on the vanilla path; not seeded.

Oracle (HeadlessConductorTests): node Deal seats 3-card hand; a vanilla
hand-card Play leaves hand (-1), adds board (+1), drops PP by cost.

Regression: 24/24 BattleEngine.Tests oracles (M2-M12) green; 241/241
SVSim.UnitTests BattleNode green. The 2 SessionEngine capture-replay shadow
tests are marked Ignore (superseded): they passed VACUOUSLY when the receive
path resolved nothing; with resolution live they hit the documented
capture-replay draw-misalignment artifact. Node-native battles are the oracle.
Drift: no drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:08:53 -04:00
gamer147
50294c10b1 test(battlenode): harness stub fails loud + non-parallelizable (M-HC-0 review)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 19:42:55 -04:00
gamer147
ca91fca028 test(battlenode): node-native battle harness for headless conductor (M-HC-0)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:37:41 -04:00
gamer147
fcc30ffe5e refactor(battlenode): drop obsolete pre-ingest spellboost peek (Phase 2 revised, O-HC-5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 19:28:21 -04:00
gamer147
fcd64c8c11 feat(battlenode): engine read surface for played-card spellboost (Phase 2 N2 Task 3 — oracle BLOCKED)
Adds SessionBattleEngine.PlayedCardSpellboost + PeekPlayedCardSpellboost (pre-resolve
read of the acting seat's hand card by Index==playIdx) and a CaptureReplay.InterleavedSends
helper. The non-circular capture oracle (engine-derived spellboost vs prod's independent
emission to cl2: idx2->1, idx14->2) is added but [Ignore]'d: the headless receive path does
not apply the wire's authoritative orderList (Deal/Swap don't seat the mulligan hand, draws
follow the seeded deck top instead of the wire move ops, plays never remove the card, alter
spellboost never accumulates), so the engine cannot yet DERIVE the count. Closing this needs
an Engine/*.cs + VfxMgr-execution logic change (escalation per the N2 playbook), not a
mechanical no-op fill. Read surface, node + engine builds, drift, and the rest of the
SessionEngine suite are green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:55:47 -04:00
gamer147
eb52890251 feat(battlenode): per-session charaId + single-active-engine gate (Phase 2 N2 carried-risk B)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:35:42 -04:00
gamer147
6e8af4e68b fix(battlenode): EngineGlobalInit guarantees full-master postcondition (Phase 2 N2 review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:29:04 -04:00
gamer147
5e0723c182 feat(battlenode): host-owned engine global init (Phase 2 N2 carried-risk A)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:19:21 -04:00
gamer147
e982300c6d feat(battlenode): inject SessionBattleEngine into BattleSession in pure shadow (Phase 2 N1 exit)
The engine is constructed per session, seated once from the master seed + both
shuffled decks (F-N-5), and fed each frame via ShadowIngest — all inside a
try/catch in ComputeFrames so a shadow failure can never break live dispatch
(ND1/ND6). Routes still come from the existing handlers: wire output is
byte-for-byte unchanged. FrameDispatchContext gains the Engine ref for N2+.

csproj: PrivateAssets=compile on the engine ref so its global-namespace type
surface (MessagePackSerializer, UserConfig, UserCard, ChallengeConfig, ...) does
not leak transitively into SVSim.EmulatedEntrypoint (which references BattleNode)
and collide with that project's own types; the runtime DLL still flows.

All 238 BattleNode unit tests pass; EmulatedEntrypoint builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:35:35 -04:00
gamer147
fa86739ac2 test(battlenode): N1 shadow replay tracks captured battle state (Phase 2 N1)
Full single-client capture replay (cl1 send=player seat, receive=opponent seat,
ts-ordered) ingests end-to-end: 33 frames, 0 rejects, 0 invariant violations at
turn boundaries (leader life/PP/board/hand).

Headless gaps filled per playbook (no Engine/ drift):
- IsRecovery=true after construction: the engine's own headless replay mode gates
  the live view/UI layer off (BattleUIContainer, turn-control UI, VFX waits) while
  keeping the live NetworkBattleReceiver (ND4) and authoritative state.
- Seed ToolboxGame.RealTimeNetworkAgent, BattleUIContainer, _backGround, and
  per-player NullPlayerEmotion no-ops the receive/turn cycle dereferences.
- _IfaceImpl.g.cs (shim, not Engine/): BattleCardView.BattleCardIconAnimations
  returns a lazy non-null no-op so the opponent card-reveal icon-init (deferred
  VFX) doesn't NRE.
- HeadlessCardMaster.Load made cumulative: it replaced the global CardMaster each
  call, so a Load(deck) evicted the oracle card set and broke tests run after.

Adds board-state accessors (LeaderLife/Pp/HandCount/BoardCount) and CaptureReplay
ts ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:28:08 -04:00
gamer147
6740313446 feat(battlenode): Receive ingests a captured PlayActions headless (Phase 2 N0)
Receive feeds the decoded frame into the mgr's own NetworkBattleReceiver
(isHaveSequence:true, checkBreakData:false — mirroring the engine's
RecoveryDataHandler frame replay), reboxing object?->object for nested data.
No engine gaps surfaced; the only fix was a test-harness one (load all deck ids
in a single HeadlessCardMaster.Load — per-id calls each replace the master).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:05:36 -04:00
gamer147
eaa7b4d85c test(battlenode): capture-replay helper + battle_test fixtures (Phase 2 N1)
CaptureReplay normalizes the capture's send/receive envelope asymmetry (send
frames carry uri at top level + bare payload body; receive frames carry a full
envelope body) and extracts selfDeck + master seed from Matched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:59:48 -04:00
gamer147
c9841c012b feat(battlenode): Setup builds two-seat network battle headless (Phase 2 N0)
Mirrors HeadlessFixture.NewNetworkEmitBattle wiring (opponent seating, leader
life, card templates, deck seeding) minus the emit-only RealTimeNetworkAgent
scaffolding (shadow only receives). Probe passed first run — M13 already filled
the network-mgr construction gaps. No Engine/ edits; drift clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:51:51 -04:00
gamer147
f6cbde723b feat(battlenode): SessionBattleEngine skeleton + types (Phase 2 N0)
SessionContentsCreator mirrors the test HeadlessContentsCreator fully (all
IBattleMgrContentsCreator members) so it compiles; Setup/Receive throw pending
the Task 3/4 probes. New files use the 'engine' extern alias.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:49:18 -04:00
gamer147
83f82efe1b feat(battlenode): reference SVSim.BattleEngine (Phase 2 N0 wire-up)
Aliased (extern alias 'engine') to confine the decompiled engine's large
global-namespace type surface, which would otherwise collide with node types
(BattlePlayer, MessagePackSerializer). Also expose internals to
SVSim.BattleEngine.Tests for the upcoming N0/N1 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 14:46:37 -04:00
gamer147
e6a561b30f test(battle-engine M13): shared NetworkEmitFixtureBase teardown — close IsForecast/agent global leak
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:03:07 -04:00
gamer147
bfd99c4829 docs(battle-engine M13): note _notEmit precondition on TryReadStockedEmitData (review polish)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:42:45 -04:00
gamer147
feb47f6437 test(battle-engine M13): best-effort emit-payload presence (Inconclusive => deferred to structural validation)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:37:24 -04:00
gamer147
73286ba78b chore(battle-engine M13): align OnEmit line-cite + HEADLESS marker spelling (review polish)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:34:53 -04:00
gamer147
ac0886389a feat(battle-engine M13): M3 spell emits PlayActions headless via OperateMgr -> NetworkBattleSender (O1 read = GO)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:23:51 -04:00
gamer147
25e9ae9573 test(battle-engine M13): NewNetworkEmitBattle harness + OnEmit capture seam
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:00:04 -04:00
gamer147
6b2c825eb8 chore(battle-engine M13): drop unused using + complete shim comment (review polish)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:56:23 -04:00
gamer147
2f6bc5b6c0 test(battle-engine M13): HeadlessNetworkBattleMgr constructs headless (construction probe)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 11:48:52 -04:00
gamer147
0fe45517da test(rng-seam): reset IsRandomDraw in RandomDrawOracleTests teardown (avoid cross-fixture leak) 2026-06-06 10:57:39 -04:00
gamer147
ffc0fcaa43 test(rng-seam): M12 oracle — scripted RNG draws a known deck card (genuine multi-outcome roll) 2026-06-06 10:46:35 -04:00
gamer147
2fd0aac5b6 test(rng-seam): M12 constants + NewAuthoritativeBattle harness factory 2026-06-06 10:40:59 -04:00
gamer147
f6e3b67be1 docs(rng-seam): note stableRandomCount divergence in HeadlessBattleMgr 2026-06-06 10:38:58 -04:00
gamer147
c47f8d9fa7 feat(rng-seam): HeadlessBattleMgr override + decoupling/parity tests (F2 resolved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 10:33:59 -04:00
gamer147
201158db5d patch(rng-seam): make StableRandomDouble/StableRandomOnlySelf virtual (DP5, zero logic change)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 10:25:35 -04:00
gamer147
1a108fa393 feat(rng-seam): ScriptedRandomSource (throw-on-overrun deterministic source) 2026-06-06 10:21:44 -04:00
gamer147
2fd42c10cf feat(rng-seam): SeededRandomSource mirrors the engine's two System.Random streams 2026-06-06 10:18:19 -04:00
gamer147
c77d789558 feat(rng-seam): IRandomSource interface + RandomSourceBridge arithmetic
Adds the RNG seam skeleton (Task 1 of M12): IRandomSource (NextUnit/NextSelf)
and RandomSourceBridge.Range mirroring BattleManagerBase.StableRandom exactly
(`(int)Math.Floor(val * unit)`). RngSeamTests pins the floor arithmetic (1 test, passing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 10:14:50 -04:00
gamer147
7370a35e9c test(battle-engine-port): M11 — gated conditional resolves headless (the GATE is the oracle)
Card 103111050 (ELF cost-1 self-buff follower) carries skill_condition
`character=me&target=self&play_count>2`. New GatedConditionalOracleTests asserts
BOTH branches of the SAME card in one fixture, varying only the seeded per-turn
play count via the public AddCurrentTrunPlayCount seam (M4/M10):

  * gate TRUE  (seed 5 > 2)  -> when_play powerup fires -> 1/1 -> 2/2
  * gate FALSE (seed 0 <= 2) -> powerup is a NO-OP (stays 1/1), BUT the card
    still pays its cost and still moves hand -> board.

This proves the engine SUPPRESSES an effect when a skill_condition is false (the
dual of "effect fires" — no prior milestone proved this), and that the gate
suppresses the EFFECT, not the PLAY. Jointly satisfiable only by a correctly-
gating engine: an always-buffs engine fails FALSE, a never-buffs engine fails
TRUE. Reuses the M4-proven buff dimension so the only new thing under test is the
conditional itself.

11/11 green; engine 0 errors; check_drift clean; ZERO new Engine copies / ZERO
shim / ZERO manifest changes — a clean milestone like M4/M6/M8/M10 (condition
evaluation is pure logic on copied engine code).

Load-bearing proof (M4/M6/M8/M10 discipline; the test passed on its first run,
which proves nothing alone): swapped the two seeds -> exactly the 4 stat
assertions failed for the right reason (formerly-TRUE branch seeded 0 took no
buff [1/1, expected 2/2]; formerly-FALSE branch seeded 5 buffed [2/2, expected
1/1]), while the cost-paid + hand->board assertions stayed green in both branches
— confirming the gate drives ONLY the effect and the card resolves regardless.
Reverted -> 11/11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 09:10:45 -04:00
gamer147
c3590e9c9b test(battle-engine-port): M10 — first dynamic {}-value card resolves headless
DynamicValueSpellOracleTests proves the engine COMPUTES an effect magnitude
from live game state (the value the wire can't carry). Card 112134010's
`when_play damage={me.play_count}-1` resolves via the proven IsForecast/
IsRecovery + ActionProcessor.PlayCard (DP4) path; the oracle asserts the
damage equals the engine's own live GetCurrentTurnPlayCount() - 1, not a
literal. Seeds play_count via M4's AddCurrentTrunPlayCount seam; lone
surviving enemy 13/13 gives a clean life-delta; selectedCards: null
(auto-target AoE). 10/10 green; zero Engine/shim/manifest changes; drift
clean. First-unknown resolved by the first RED: the per-play +1 lives in
OnBeforePlayCard (wired only via OperateMgr/Prediction), so the direct-
ActionProcessor harness reads exactly the seeded count (damage == seeded-1);
load-bearing proven by varying the seed 4->7 and watching damage track 3->6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 09:02:59 -04:00
gamer147
eee8450144 feat(battle-engine-port): M9 COMPLETE — when_play draw resolves headless (hand/deck-delta oracle)
Proves the deck->hand transfer dimension (design §5 draw oracle) — the last
deterministic, non-RNG card-effect class no prior milestone touched (M3/M4/M6/M8
moved stats, M2/M5/M7 the board, M3 the leader).

Card 800114010 (clan-1 ELF cost-1 when_play draw 1 from own deck, ungated, no
evo/preprocess). The resume-guide's skill_target=none/no-RNG shape does not exist
in cards.json — EVERY draw selects from the deck via a random_count filter
(skill_option is always literally 'none'). RNG neutralized structurally: seed the
deck with EXACTLY ONE known card so random_count=1 is deterministic regardless of
seed. New primitive HeadlessEngineEnv.SeedDeck (create via the null-view seam +
engine AddToDeck). Oracle DrawSpellOracleTests asserts: seeded card moves deck->hand
(by id + by reference), deck -1, drawn card IsInHand, spell pays cost + leaves hand
+ resolves to cemetery, board/opponent untouched. Load-bearing confirmed the M7 way
(seed a different id -> the by-id assertion fails).

Shim gap fixed (the predicted M9 cost): Skill_draw's BattleLog tail
(UpdateFusionedCardSkillDrewCard, unguarded; + the IsBattleLog AddLogSkillDrawCard
calls) dereferences BattleLogManager.GetInstance(), an M1 'default!' null singleton
-> NRE after the draw already committed. One-line HEADLESS-FIX (M9) in
BattleLogManager.g.cs returns the existing _instance singleton (all its methods are
no-ops), per the M2/M7 Null*-singleton playbook. No Engine/ edit (drift clean).

9/9 green; check_drift.py clean; engine still 0 Error(s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:47:04 -04:00
gamer147
4f76fb21f0 feat(battle-engine-port): M8 COMPLETE — lethal damage proves follower death via combat math
A when_play damage=5 spell (the M6 card 800134020) played at a select_count=1
enemy follower with life <= 5 kills it as a consequence of damage -> life <= 0 ->
the dead-check + the same RemoveInplayCard/cemetery path M7 lit up (the dominant
real-card removal mechanic), reached through combat math rather than `destroy`.

Oracle LethalDamageSpellOracleTests: selected follower (1/2) removed (board -1 +
cemetery +1, the M7 dimension); un-selected control (6/7, life > 5) untouched and
still on board (M6 routing; select_count=1 hits only the selected target). 8/8
green; engine 0 errors; check_drift clean; ZERO new Engine/shim/manifest work —
the death path inherited M7's death-voice fix; the predicted damage-VFX shadow
never materialized.

Load-bearing (M4/M6 discipline): swapping the selection to the 6/7 -> it survives
at 2 and nobody dies, proving removal is gated on the SELECTED follower's life
reaching <= 0, not on selection (M7's destroy) or a blanket wipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:35:02 -04:00
gamer147
9fc97abee7 feat(battle-engine-port): M7 COMPLETE — targeted destroy resolves headless (follower death / board-removal)
First proof that follower DEATH / board-removal commits in the authoritative
part of PlayCard headless (not the cosmetic post-Process tail). Card 800144120
(cost-0 when_play destroy of a select_count=1 enemy follower) resolves via the
M6 selectedCards path: selected enemy follower removed (board -1 + cemetery +1),
un-selected untouched (routing confirmed load-bearing by swapping the selection).

Shim gap fixed (the predicted M7 cost): SkillProcessor.SelectCardToHaveDestroyVoicePlay's
cosmetic death-voice tail NRE'd on three M1 default!/Null* shadows
(IBattleCardView.VoiceInfo, CardVoiceInfoCache.GetCardVoiceInfoForBattle,
ReadOnlyVoiceInfo.GetDestroyVoice — the last unusable as the interface since
m1_stub_gen dropped its : IReadOnlyVoiceInfo base). Fix = one hand shim
HeadlessVoiceInfo : IReadOnlyVoiceInfo returning the engine's own
VoiceAndWaitTime._nullVoice sentinel, wired into the two generated seams with
// HEADLESS-FIX markers. No Engine/ edit (drift clean).

dotnet test SVSim.BattleEngine.Tests -> 7/7 green; check_drift.py clean; engine 0 Error(s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:23:53 -04:00
gamer147
c8314bd3c0 test(battle-engine-port): M6 COMPLETE — targeted when_play damage spell resolves headless (selection-routing oracle)
First card to exercise the selectedCards path of ActionProcessor.PlayCard
(dormant through M2-M5, all of which played selectedCards: null). Spell
800134020 (clan-1 cost-1, when_play damage=5 to a select_count=1 enemy
follower) resolves headless: with two vanilla followers on the enemy board
and one passed as selectedCards, the damage hits ONLY the selected follower
(13->8) and the un-selected one is untouched (7).

New oracle dimension: SELECTION ROUTING via a differential life-delta on two
surviving targets (selected -5, un-selected 0) — reads the authoritative
damage path M3 proved, with no dependence on follower death/board-removal
timing. Load-bearing confirmed (M4 discipline): swapping which follower is
selected makes the damage follow the selection (assertions fail for the right
reason), then reverted to green.

Like M4, a clean milestone: NO new engine/shim work — the selectedCards path
resolved on the existing shim surface. The only authoring was test-side: the
M6 card constants, a shared HeadlessEngineEnv.PutFollowerInPlay primitive
(create via the null-view seam + drive HandCardToField), and the oracle.

Engine still 0 errors; check_drift clean; dotnet test -> 6/6 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:08:01 -04:00
gamer147
62a28fe2d4 feat(battle-engine-port): M5 COMPLETE — summon_token spell resolves headless (board-count delta oracle)
Card 800134010 (clan-1 cost-1 ungated spell, summon_token=100011020): a when_play
summon places one new neutral 2/2 follower token on the caster board. New oracle
dimension = board-count + token-identity delta from a SKILL-CREATED card. 5/5 green;
engine 0 errors; check_drift clean; zero new Engine copies.

This is the first headless run of the PUBLIC prefab card-creation path
(CardCreatorBase.CreateCard, createNullView:false) — engine-internal card creation
(summon/draw/token) has no null-view path in solo mode, unlike the M2-M4 hand-card
seam. Built that path headless:
- Self-consistent no-op Unity object graph (UnityShim.cs): Component.gameObject/
  transform, GameObject.transform, Transform.parent/Find now lazily non-null +
  cached; GetComponent routed through the GameObject component model.
- Targeted NGUI material backing-field wiring (UIFont.mMat / UILabel.mMaterial) so
  the copied material getters return non-null via their simple branch (blanket/deep
  wiring would make them delegate down a re-nulling chain).
- getUIBase_CardManager() default! -> field-wired no-op via new ShimView.Create<T>().
- Test-side seeds: SBattleLoad card templates + 3D scene GameObjects (InitCardTemplates).

Load-bearing proof: swapping to the M3 non-summoning spell fails the board-count
(Expected 2, was 1) + token-not-found assertions; reverted to green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 03:19:47 -04:00
gamer147
b13cfa0fad test(battle-engine-port): M4 COMPLETE — when_play self-buff follower resolves headless (4/4 green)
Fold SetupCardEvent into a shared HeadlessEngineEnv.CreateHeadlessHandCard primitive
(consolidating the duplicated M2/M3 helpers), then add the M4 oracle: card 103111050
(ELF cost-1 1/1, when_play powerup add_offense=1&add_life=1 to target=self). New oracle
dimension = the played card's OWN stat delta (1/1 -> 2/2). Gate play_count>2 seeded via
the public AddCurrentTrunPlayCount; proven load-bearing (without the seed the fanfare
gates out and Atk stays 1). No new shim/data gaps were needed — only harness seeding.
Engine still 0 errors; check_drift clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 02:36:02 -04:00
gamer147
c47ae93027 feat(battle-engine-port): M3 COMPLETE — fixed-damage spell resolves headless (leader-life-delta oracle passes)
Card 900124030 (ELF cost-3, when_play damage=3 to enemy leader) resolves to
correct authoritative state headless via the IsForecast/IsRecovery +
ActionProcessor.PlayCard path. New oracle dimension (opponent leader-life delta)
passes; 3/3 tests green; engine still 0 errors; check_drift clean.

Four headless gaps, each mechanical (no logic/Unity wall):
- Data seam: InitLeaderLife (SetupInitialGameState->InitializeClassLife subset);
  leader BaseMaxLife was 0 => game-over => play silently rejected. M2 missed it
  (only asserted leader life unchanged: 0==0).
- Runtime cast: re-attach IClassBattleCardView on the generated
  NullClassBattleCardView stub (members already present; base-clause recovery
  stripped the decl). Compiled fine -> M1 loop never surfaced it.
- M1 mis-cut: copy NullVfxWithLoading verbatim (its GetInstance() lazy singleton
  was stubbed to default!/null). Same pattern as M2 NullCardVfxCreator.
- Card events: CreateHeadlessHandCard now calls SetupCardEvent so a spell's
  OnPlay->RemoveSpellCardFromHand / OnFinishWhenPlaySkill->AddSpellCardToCemetery
  fire (the bare CreateCardWithoutResources seam skips them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 02:19:54 -04:00
gamer147
171f07ec74 feat(battle-engine-port): M2 COMPLETE — vanilla follower resolves headless (go/no-go = GO)
First green: a zero-skill vanilla follower (100011010, neutral 1/2) resolves to
correct authoritative state HEADLESS via IsForecast/IsRecovery + ActionProcessor.
PlayCard (DP4), no Unity runtime. §5 oracle passes (PP-cost; hand->in-play;
atk/health == CardCSVData base; opponent unchanged; no exception). VERDICT: the
port approach is validated through the resolution path, not just M1's compile path.

VanillaFollowerOracleTests.Vanilla_follower_resolves_to_correct_state — GREEN.
HeadlessCardMaster now loads the follower's real id from cards.json.

Resolution-path shim/engine gaps closed (all mechanical no-op fills or data seams,
never a Unity/logic wall):
- M1 mis-cut copies (DP1/DP3 — pure no-op logic wrongly stubbed to null):
  Engine/Wizard.Battle.View.Vfx/NullCardVfxCreator.cs (its GetInstance() singleton
  was nulled) + its dep NotEmptyNullVfx.cs. Deleted the generated NullCardVfxCreator
  stub + its _IfaceImpl block; both manifested, check_drift clean.
- _IfaceImpl explicit-impl shadow: interface-typed view/mgr calls dispatch to the
  explicit impls (which returned default!), shadowing public stubs. Fixed
  IBattlePlayerView.GetSideLogControl (SkillProcessor side-log tail) to return a
  non-null no-op. KEY M3+ learning: fix _IfaceImpl.g.cs for interface-typed NREs.

(GameMgr/component-model/Resources/IClassBattleCardView shim fills + CardIconControl
copy + the SVSim.BattleEngine.Tests project landed in the prior commit 2b50657.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 01:57:15 -04:00
gamer147
2b506574e7 feat(battle-engine-port): M2 step 1 — SingleBattleMgr constructs headless
First green of the M2 go/no-go probe: `new SingleBattleMgr(StandardBattleMgr-
ContentsCreator)` now builds the two-player pair fully headless against the shim,
no Unity runtime. Verdict: headless construction is feasible; every blocker was a
mechanical no-op shim fill or data seam, not a Unity/logic wall.

Shim fills (authored):
- GameMgr: lazy non-null DataMgr/PrefabMgr/InputMgr/SoundMgr/BattleControl.
- GameObject: lazy cached component model so GetComponent<T>/AddComponent<T> return
  non-null no-op instances for Component-derived T (F1: unguarded view touches).
- Resources.Load(string): cached non-null GameObject so the prefab->Instantiate->
  GetComponent chain (UnityEventAgent) yields a real object.
- ClassBattleCardViewBase: re-attach dropped IClassBattleCardView (no-op members);
  ClassBattleCardBase.Setup casts the created view to it.

Engine copy (DP1/DP3 mis-cut fix):
- CardIconControl.cs copied verbatim (manifested) + generated null-stub deleted.
  SplitAndCompleteIconStr is pure string logic on the resolution path that M1 had
  wrongly stubbed as "View" -> null deref in SkillCreator.CreateBuildInfo.

Test harness (SVSim.BattleEngine.Tests, authored fixture):
- HeadlessContentsCreator/HeadlessPhaseCreator: deterministic replica of the solo
  practice init (StandardBattleMgrContentsCreator + SingleBattlePhaseCreator) with
  no-op recovery/replay managers.
- HeadlessCardMaster: reflects the loader cards.json dump into CardMaster.
- HeadlessMasterData: minimal Data.Master (class-character list, empty collections)
  + Data.Load + player/enemy chara ids.
- ConstructionProbeTests.SingleBattleMgr_constructs_headless — GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 01:36:22 -04:00
gamer147
1078b1ef50 port(m1): wave 7k — M1 COMPLETE: 0 compile errors, headless engine builds (12->0)
Final three clusters:
- RoomParamKey: copy Wizard.RoomMatch/RoomParamKey.cs verbatim (UriNames/WatchUriNames
  static dicts keyed by PlayerController.ROOM_URI + PlayerControllerForWatching.
  SEND_PARAMETER — both now real enums).
- CardChooseTask: copy the TwoPick/CardChooseTask.cs (TaskManager `using`s .Arena.TwoPick,
  not .Competition — copy_loop had only landed the Competition twin).
- SetCardNumLabel CS1739: decompiler param-name artifact — the local fn's 3rd param was
  recovered as `flag` but call sites pass it named `isRed:`. First DP5 tracked patch:
  Engine/UICardList.cs edited (flag->isRed, zero logic change), recorded in
  Patches/ + manifest patched=1 (drift-clean).

M1 exit criteria met: `dotnet build SVSim.BattleEngine` = 0 errors, no Unity ref in csproj,
check_drift clean. Session 7: 198 -> 0 across waves 7a-7k.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 01:03:58 -04:00
gamer147
f63d1cc2e2 port(m1): wave 7j — Material/Plane/Socket overloads + IDictionary extension (24->12)
- Material.SetVector(int nameID, Vector4); Plane(Vector3, float d) ctor; Socket.On
  (SocketIOEventTypes, callback) overload.
- Global GetValueOrDefault(this IDictionary<,>) extension — the BCL form only binds to
  IReadOnlyDictionary, so the IDictionary call mis-resolved to JsonDataExtension.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 01:00:12 -04:00
gamer147
ad58994b8e port(m1): wave 7i — RoomMatch/Story/Effect app members + ROOM_URI enum (40->24)
- PlayerController.ROOM_URI nested enum (verbatim-generated).
- RoomRoot.CreateChangeSceneDialog, StoryRecoveryData.ToJsonData,
  BattlePlayerViewBase.IsSelecting, StorySelectionWorldScene.RedirectSectionId,
  EffectMgr.LoadAndInstantiate2dEffectCoroutine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:57:51 -04:00
gamer147
59cb089c97 port(m1): wave 7h — Unity overloads + SDK return types + RoomRoot:UIBase (56->40)
- Resources.LoadAsync(string) non-generic, LayerMask implicit-from-int, Animation
  string indexer.
- SDK return-type fixes: RedShellSDK.MarkConversion/LogEvent return IEnumerator
  (StartCoroutine arg), Packsize.Test() returns bool (!Test()).
- RoomRoot : UIBase (decomp base) so TopBar/Footer's (RoomRoot) casts succeed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:54:47 -04:00
gamer147
3a88b27752 port(m1): wave 7g — Unity coroutine/overload + app-member tail (88->56)
- MonoBehaviour.StopCoroutine(string) (iTween/NGUI StopCoroutine("name")),
  Object.DestroyImmediate(o, bool), GetComponentInParent<T>(bool includeInactive).
- App members: TitlePanelBase (:MonoBehaviour + IsFinishInit), PlayerController.Target,
  DialogManager.CreateDialogBaseOpenCardDetail, BattleLogWindow.HideCardListPanel,
  DetailPanelTouchProcessor.StopAttackTarget, StoryRecoveryData.ChapterCharaId +
  (SelectedStoryInfo) ctor overload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:52:18 -04:00
gamer147
5c5a58af3c port(m1): wave 7f — VFX containers / Create factories / dropped event / ctor cascade (112->88)
- VFX: SequentialVfxPlayer.GetAllVfxAsList, ParallelVfxPlayer.GetVfxList,
  VfxMgr.CheckAndAddEffectVfxList; point our own ShowBattleUIImmediatelyVfx stub at
  NullVfx.GetInstance() (it called a non-existent NullVfx.Create).
- Static factories: SkillTargetSelectTouchProcessor.Create (10-arg),
  DialogReportToManagement.Create(long).
- Re-add StartSkillSelectVfx.OnStart event (m1_stub_gen drops `event` decls — the
  recurring session-6 gap; generator event-capture fix still pending).
- Stop the BattleCardView/GameObjMgr ctor cascade: parameterless ctors on the no-op
  BattleCardView and GameObjMgr hand shims so non-chaining subclass/field stubs satisfy
  their implicit base() call.
- Copy Cute/ListExtensions.cs (FisherYatesShuffle extension) verbatim into Engine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:49:07 -04:00
gamer147
981f903504 port(m1): wave 7e — Unity/NGUI/Spine member tail (142->112)
- isActiveAndEnabled onto Behaviour (real Unity location) — clears it on all 5
  MonoBehaviour-derived NGUI types (MyPageCardPanel/WizardUIButton/UITweenAlpha/
  UIScrollView/UICardList) in one edit.
- Touch.tapCount, Rigidbody2D.isKinematic, AnimatorStateInfo.fullPathHash,
  AnimationClip.frameRate.
- Spine: SkeletonData.Skins (List<Skin>) + Skin.Name, for SpineObject's
  Data.Skins.Any(s => s.Name == ...).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:44:04 -04:00
gamer147
7d3d92981e port(m1): wave 7d — LoginBonus/Story data ctors + nested BuildInfo/FileNamePair (158->142)
- Add the (JsonData) ctor to the LoginBonus data hand stubs (Continuous/Normal/
  Special/FreeCardPackBox) and StoryRecoveryData (LitJson.JsonData is copied).
- Full-surface the two nested View types that only the parent's empty stub covered:
  BattleCardView.BuildInfo (14-arg ctor) and DestroyVfx.FileNamePair (ctors +
  ObjectFileName/SeFileName); add BattleCardView(BuildInfo) to the hand shim.
- Regenerate Field/Spell/UnitBattleCardView: stale stubs whose ctors had dropped the
  decomp `: base(buildInfo)` chain, exposed (CS7036) once BattleCardView lost its
  implicit default ctor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:42:03 -04:00
gamer147
57f1f0c25e port(m1): wave 7c — SelectionProcessing Parameter + Touch-processor ctors (174->158)
- Generate both SelectionProcessing Parameter classes namespace-aware (full-surface
  captures the 8-arg Main / 6-arg BattleResult ctors); drop the empty hand stubs.
- Add the missing decomp ctors to the 5 empty Touch-processor hand stubs
  (SetCard/EvolutionSimple/Emotion/ClassBuff/DetailPanel) — compile-only ballast,
  empty bodies.
- Regenerate FusionWaitProcessor.g.cs: it was a stale stub whose ctor had dropped
  its decomp `: base(...)` initializer; harmless while SetCardProcessor had an
  implicit default ctor, exposed (CS7036) once the parameterized ctor landed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:38:58 -04:00
gamer147
38ab33a765 port(m1): wave 7b — Main-namespace dialog dupes + IReplayRecordManager (190->174)
Generate the Main-namespace versions of the four colliding SelectionProcessing
dialog classes (ChapterCharaDecider/DownloadInfoGetter/DeckSelectionDialogDisplay/
DeckSelectionConfirmDialogDisplay) via the new --ns path — AreaSelectUI uses the
Main module and constructs them into an IProcessing[]. baseclauses binds each to
Main.ProcessingBase; iface_reattach (regenerated full) attaches Main.IProcessing.

Also fill IReplayRecordManager with its 3 real members (SetupRecording/
SetupBattleInfoFilter/SetupOperateMgrEvents); both implementors already had them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:34:12 -04:00
gamer147
fc54dac081 port(m1): wave 7a — namespace-aware ProcessingBase collapses Story SelectionProcessing cluster (198->190)
The Story chapter-selection processing subsystem is duplicated across two
namespaces (…SelectionProcessing.Main and .BattleResult), each with its own
ProcessingBase : IProcessing + Parameter. m1_genstub keyed output by bare type
name, so only ONE ProcessingBase.g.cs was emitted (BattleResult), and
m1_baseclauses cross-qualified the Main leaves to BattleResult.ProcessingBase —
making it impossible to give IProcessing its real members (Execute(Main.Parameter)
≠ inherited Execute(BattleResult.Parameter) → CS0535).

Now both ProcessingBase variants are generated via the namespace-aware tooling
(<Type>__<Namespace>.g.cs), baseclauses resolves each leaf to its same-namespace
ProcessingBase, and both IProcessing interfaces carry NextProcessing + Execute.
8 IProcessing CS1061 cleared, no CS0535 introduced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:31:29 -04:00
gamer147
6e9c5c059f port(m1): wave 6i — Networking/Facebook/BestHTTP CS0103 statics (210->198)
- UnityEngine.Networking: UnityWebRequestTexture, DownloadHandlerTexture, DownloadHandlerAssetBundle.
- Facebook.Unity.AccessToken (CurrentAccessToken/TokenString/UserId).
- BestHTTP.Decompression.Zlib.GZipStream.UncompressBuffer; global DllCheck.Test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:20:22 -04:00
gamer147
8bb392dcd6 port(m1): wave 6h — CS0246 app types + Unity members/enums (236->210)
- Generated app types: ChapterCharaDecider, DownloadInfoGetter, DeckSelection(Confirm)DialogDisplay,
  SubChapterStorySectionBtn, EvolutionHideMessageVfx; nested OpeningShowCharacterPanelVfx on OpeningVfx.
- EffectMgr.MoveType: full 47-value decomp enum (was 4).
- MonoBehaviour.print, Debug.isDebugBuild, LayerMask.LayerToName,
  SystemLanguage.Chinese, RuntimePlatform.XBOX360/BlackBerryPlayer/+console values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:15:46 -04:00
gamer147
d4364ae4b1 port(m1): wave 6g — CS1061 member cluster (304->236)
- Friend.PlayerData full-surface generated (18 members) + hand stub -> partial.
- Wizard.RoomMatch.Player: 7 friend-info props (ViewerId/Name/Rank/Emblem/Degree/Country/IsFriend).
- GameMgr: HasAuthAdmin, ChangeAspectRatio(float), Update().
- Cute.SceneManager: ChangeScene overloads + SceneChangeParameter (+SceneChangeParameter stub).
- UnityEngine.SceneManagement.SceneManager + Scene; RedShellSDK.RedShellSDK statics.
- Packet.Attachments; Vector2.Dot/Angle/Normalize.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:11:43 -04:00
gamer147
e5e05deadb port(m1): wave 6f — Unity primitive operators (362->304)
CS0019 operator gaps on value-type shims:
- Vector2: ==/!=, Vector2*Vector2, Equals/GetHashCode.
- Vector4: *float, +/-, ==/!=, Equals/GetHashCode.
- Color: ==/!= (Color==Color32 via existing implicit conv), Equals/GetHashCode.
- Rect: ==/!=; Matrix4x4: *, GetColumn/GetRow/indexer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:08:11 -04:00
gamer147
8c9fe7a1b9 port(m1): wave 6e — Unity ctors + Unity/ZXing/SFB type stubs (444->362)
CS1729 Unity ctors: Plane(3pt), Texture2D(w,h,fmt,mip), Keyframe(4),
  AnimationCurve(params Keyframe[]), WebCamTexture(name,w,h,fps), UnityWebRequest(url,method).
CS0246 Unity: AnimationState, GUIContent, TextEditor, WebCamDevice, Display,
  WaitForSecondsRealtime, AnimationBlendMode + WebCamTexture.devices.
CS0246 SDK: SFB ExtensionFilter/StandaloneFileBrowser; ZXing BarcodeFormat/Result/
  BarcodeWriter/BarcodeReader/QrCodeEncodingOptions/ErrorCorrectionLevel.
NullBattleCardView parameterless ctor (CS7036).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:06:03 -04:00
gamer147
629ae6bf98 port(m1): wave 6d — Unity method/ctor overloads (572->444)
CS1501 overload gaps (Unity):
- Transform.TransformPoint/InverseTransformPoint(float,float,float); LookAt(.,worldUp) x2.
- Object.FindObjectsOfType(Type)/(bool); Instantiate<T>(.,pos,rot,parent).
- Component/GameObject.BroadcastMessage(string,object[,opts]).
- Animator.Play(string/int, layer[, normalizedTime]).
- Mathf.Min/Max(params float[]/int[]).
- MonoBehaviour.CancelInvoke(string).
CS1729: NullBattleCardView(BuildInfo) ctor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:00:11 -04:00
gamer147
9376b35db2 port(m1): wave 6c — Unity + Steam/FB/Adjust static-class shims (696->572)
Off-battle-path static surfaces (CS0103 cluster):
- UnityStatics: Gizmos, Physics2D, Caching, GUIUtility, Cursor, ColorUtility,
  ScreenCapture, RenderSettings, JsonUtility, Social + CursorLockMode enum.
- RaycastHit2D implicit-bool operator; ILocalUser in SocialPlatforms.
- Steamworks: Callback<T>.Create, AppId_t/CSteamID/HAuthTicket/SteamNetworkingIdentity,
  MicroTxn/GetAuthSessionTicket response structs, SteamAPI/User/Utils/Client statics.
  Removed empty dup GetAuthSessionTicketResponse_t from ThirdParty.cs.
- Facebook.Unity: FB + ILoginResult + FacebookDelegate<T>; com.adjust.sdk.Adjust;
  global TimeNativePlugin/Packsize native-plugin stubs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 23:56:52 -04:00
gamer147
755f7fd148 port(m1): wave 6b — View base members + app-type stubs (772->696)
- BattleCardView shim: GameObject, HandFrameEffect, GetCurrentIconLayout (fixes Player/EnemyClassBattleCardView.GameObject inheritance).
- BattlePlayerViewBase.AlwaysShowStatusPanel; NullBattleCardView.ReleaseSharedDummy.
- EvolutionTouchProcessor: 4 events (OnFocus/Unfocus/Select/NotSelect Target) hand-added — m1_stub_gen drops `event` decls.
- Generated full-surface stubs: StoryWorldDataManager, GenerateDeckCode, GameSetup, CommonPrefabContainer, ApplicationFinishManager, EvolutionConfirmation, ReplayDataHandler (hand stubs -> partial).
- Closure pulled by StoryWorldDataManager full-surface: 4 verbatim copies (StoryChapter/Summary/LeaderSelect dialogs, ClassIconName) + empty stubs StoryWorldData/BattleRecovery/ResourceDownloader/TemporaryAssetDeleter (non-battle, signature-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 23:50:10 -04:00
gamer147
67f91e230e port(m1): wave 6a — GameMgr setters, Tab MonoBehaviour, OpeningVfx members, Vfx stubs (838->772)
- GameMgr.Is{Network,AINetwork,Watch,Replay,Puzzle,AdminWatch}Battle: read-only => settable (CS0200, Matching/NetworkBattleManagerBase assign them).
- Tab : MonoBehaviour (inherits Object.name; CS1061 x8).
- OpeningVfx: static OpenningLogStep, ShowBattleUIImmediatelyVfx (NullVfx, F1 contract), nested WaitVoiceEndVfx (CS0117/0426 x14).
- Generated no-op stubs EffectBattleVfxBase/SkillEffectBattleVfx/FallToGroundVfx/ThinkIconShowVfx (: SequentialVfxPlayer chain), baseclauses reattached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 23:43:47 -04:00
gamer147
db76808e64 feat(battle-engine): re-attach interfaces dropped by base-clause recovery (958->838)
base-clause recovery strips interfaces (to dodge CS0535), but copied code converts
the stubs to those interfaces -> ~120 CS0266/CS1503. Two mechanisms:
- _IfaceImpl.g.cs: explicit no-op impls of the FULL (copied) interfaces, layered
  onto each hierarchy base (BattleCardView/CardVfxCreatorBase/BattlePlayerView/
  BattleEnemyView/ClassInfomationUIBase + NullCardVfxCreator). Explicit form never
  collides with existing members; leaves inherit. Walks base-interface chains
  (IPlayerView : IBattlePlayerView) and emits events.
- _InterfaceReattach.g.cs: plain ': IFoo' for the empty stub interfaces
  (IProcessing, IReplayRecordManager).
- ClassBattleCardViewBase/NullBattleCardView: restore dropped BattleCardView base
  so they inherit its IBattleCardView impl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 23:33:14 -04:00
gamer147
be10425819 feat(battle-engine): VfxWith ctor arg order + Unity conversions + ITouchProcessor reattach (1102->958)
- VfxWith<T> ctor params were swapped ((T,VfxBase) vs decomp (VfxBase,T)) -> ~38
  CS1503 across SkillCollectionBase/BattleCardBase skill-processing (ProcessInfo
  <-> VfxBase). These are on the resolution path. Fixed to match decomp.
- UnityEngine primitives: implicit Vector3/Vector2<->Vector4 + Color->Color32
  conversions; Transform.Translate/Rotate(Vector3,Space) overloads (iTween).
- ITouchProcessor was dropped from touch-processor stubs by base-clause recovery;
  re-attach via Shim/View/TouchProcessorIfaces.cs (interface-only for generated
  full-surface stubs, interface+no-op members for the empty hand stubs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 23:18:35 -04:00
gamer147
795f7a6bc8 feat(battle-engine): preserve ctor base-initializers + Event/Reward shims (1386->1226)
- Regenerate 31 VFX/View/UI/Touch stubs to keep their decomp ': base(...)' /
  ': this(...)' ctor initializers (m1_stub_gen was dropping them -> CS7036/CS1729
  when the copied base has no parameterless ctor). Whole base-ctor cluster cleared.
- UnityEngine.Event: add rawType/keyCode/modifiers/Use() + EventType enum (NGUI
  UIInput/UIInputOnGUI legacy IMGUI path).
- Reward: copy the real Wizard.Scripts.Network.Data.TaskData.Arena.Reward verbatim
  (was an empty ambiguous-name shim in LooseEnds); deps (UserGoods/JsonData) present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 23:10:52 -04:00
gamer147
7e5ff0a58f feat(battle-engine): ParticleSystem/Collider2D/Quaternion + SocketOptions members (1462->1386)
ParticleSystem.MainModule (playOnAwake/simulationSpeed/startColor + MinMaxGradient),
ParticleSystemRenderer (maskInteraction/trailMaterial + SpriteMaskInteraction), BoxCollider2D
(isTrigger/offset/size), Quaternion.FromToRotation/Inverse. SocketOptions (AutoConnect/
ConnectWith/AdditionalQueryParams) + PlatformSupport ObservableDictionary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:43:39 -04:00
gamer147
7ac13f73f2 feat(battle-engine): BestHTTP SocketIO + Spine SDK member shims (1556->1462)
BestHTTP.SocketIO: Socket.On/Off/Emit + SocketIOCallback delegate, SocketManager ctors/
State/Socket/indexer/Open/Close/SettingRealtimeNetworkAgent, SocketOptions. Spine: Skeleton
(Data/Skin/Scale/FindBone/SetSkin/Update), Bone (WorldX/Y/RotationX), SkeletonMecanim
(MonoBehaviour + skeleton). All minimal hand shims (no full-surface -> no SDK closure pull);
node-socket path is Phase-2, off the battle path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:41:06 -04:00
gamer147
a1c0c2d312 feat(battle-engine): CRI/Unity overload + generated base-ctor fixes (1586->1556)
CRI: CriAtomExPlayer.AttachFader, CriAtomCueSheet.acb, CriAtomExCategory static.
Unity overload gaps (CS7036): Transform.Translate/Rotate(float,float), Vector4(3/2-arg)
ctors, Vector3 instance Scale. Parameterless ctors for generated Vfx bases (ForecastIcon
VfxBase/ShowChantCountVfx/EvolveVfx) whose derived stubs' implicit base() failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:38:02 -04:00
gamer147
4be630bd09 feat(battle-engine): full-surface app-type god-object/manager stubs (1692->1586 true)
Make the minimal hand shims partial + generate full member surface for the manager/
task/controller god-objects (LoadingViewManager/DeckUpdateTask/MyPageTask/ReplayController/
PlayerControllerForWatching/WatchDataHandler/EvolutionTouchProcessor/StoryChapterSelection
Utility/NonDialogPopup). NonDialogPopup given MonoBehaviour base + hand Close() removed
(superseded by full surface). LoadTask dup deleted (already copied verbatim). RoomMatch
watch/replay closure types stubbed. Copied 8 more closure files.

CS0246-in-generated-signature masking note: 4 such errors were hiding ~1582 — generated
CS0246 masks as hard as header CS0246; the real frontier is 1586 (CS7036 base-ctor +
member-level), 0 structural.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:33:37 -04:00
gamer147
fce02a6250 feat(battle-engine): VFX container Create overloads (2202->1692)
The hand-shim VFX containers only had no-arg Create(); the engine calls them with
collection/params/loading-main args (510 CS1501). Add the real decomp Create overloads
to SequentialVfxPlayer/ParallelVfxPlayer/VfxWithLoading/VfxWithLoadingSequential.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:27:51 -04:00
gamer147
2ddc86943e feat(battle-engine): re-establish dropped base clauses for net-new stubs (2704->2202 true)
The stub generator emits net-new types as base-LESS partials, so generated Vfx/View
types weren't actually VfxBase/etc. -> hundreds of CS1503/CS0029 'cannot convert to
VfxBase' at every polymorphic call site. m1_baseclauses.py recovers each generated
type's decomp base CLASS (interfaces dropped to avoid CS0535) into _BaseClauses.g.cs,
cross-namespace bases fully qualified. Generated the intermediate Vfx/processing base
types (SpreadOutVfx/OpenCardVfx/ProcessingBase/DamageVfxBase/ForecastIconVfxBase/...).
DefaultOpeningVfx regenerated WITH override (its base OpeningVfx is copied+abstract).

Clearing the polymorphism cascade + the masking base-type CS0246s unmasked the true
member-level frontier: 2202 (CS1501/CS1061/CS1503), 0 structural errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:24:22 -04:00
gamer147
d01e3da869 feat(battle-engine): UnityEngine member + static-class shims (3526->2706)
Extend the UnityEngine value/component shims with no-op members surfaced by the compile
loop (UnityWebRequest/Font/Mesh/LODGroup/AudioSource/Rigidbody/Camera/Sprite/Animation/
Transform/Material/Texture2D/Light/Input/Resources + CharacterInfo/Vector4), via partial
declarations + UnityShimExt.cs. Add the missing UnityEngine static classes (PlayerPrefs/
Physics/GUI/SystemInfo/Graphics/QualitySettings/StackTraceUtility) + enums (TextureFormat/
ColorSpace/EventModifiers/RenderTextureReadWrite) + Experimental.Rendering.GraphicsFormat*
in UnityStatics.cs. All cosmetic, off the battle path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:14:23 -04:00
gamer147
9cd3f40a2f feat(battle-engine): CRI Atom/Mana audio+movie shim (3916->3526)
Hand-model the CRI ADX2 (audio) + CRI Mana (movie) SDK surface exercised by the copied
audio/movie engine files (AudioManager/Voice/Se/Effect/MoviePlayer). No decomp source
exists; signatures mirror the real CRI API as called at the sites (arg counts/types from
the call sites). All no-op, cosmetic, off the battle path. Reconciled with the empty CRI
stubs already in SdkStubs (CriAtomExAcb/CriAtomExPlayback/CriManaMovieMaterial).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:08:36 -04:00
gamer147
4b9a603cd4 feat(battle-engine): full View/VFX/UI/Touch/Story type closure (4254->3916, unmasked)
Generate no-op shells for the entire stop-listed View/Vfx/UI/Touch/Story missing-
type closure (~180 types) + 5 copyable engine files. Net-new shells emitted base-less,
so override members are stripped via the new --no-override generator flag. SDK/BCL
over-reach (Adjust/GZipStream/Socket*) and non-battle Story-world clusters reduced to
minimal/empty stubs instead of full-surface. Nested-type closure (BuildInfo/BattleDialog/
ROOM_URI/FuncGetCantAttackText) placed top-level in their decomp namespaces.

Clearing the last View CS0115 unmasked the true member-level frontier: 3916 errors,
0 generated/structural errors, now dominated by Unity-type + god-object members.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:01:37 -04:00
gamer147
a28e3ba334 feat(battle-engine): Unity shim members — BoxCollider/RenderTexture/AnimationCurve/Animator (4572->4254)
Add no-op members + supporting types (FilterMode/TextureWrapMode/WrapMode/Keyframe/
AnimatorStateInfo/AnimatorClipInfo) to the UnityEngine shim. Standard Unity API surface,
inferred from frontier member names — Unity types aren't in the decomp so they're
hand-extended, not generated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:40:37 -04:00
gamer147
f32492b6c9 feat(battle-engine): app-type wave (RoomBase/Avatar/BossRush/tasks) 4850->4572
Full-surface stubs for RoomBase, Avatar/BossRush/MyRotation battle-log items (MonoBehaviour),
GetDeckDataFromCode, MailTopTask, AccountTransferHelper, CanNotTouchCardVfx. EXCLUDE
inherited overrides (CanNotTouchCardVfx.IsEnd, MailTopTask.Parse). ClosureStubs for the
RoomMatch subsystem bleed (~11 Room* types) + AppleLogin(+Error). Reward/Event deferred
(ambiguous common names resolve to wrong SDK files).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:37:23 -04:00
gamer147
70a2c3e8ed feat(battle-engine): View/Room/Vfx type wave (5600->4850)
Full-surface stubs for ICardVfxCreator(iface), SelectedStoryInfo, ImageSelection,
IReadOnlyVoiceInfo, RoomConnectController(+InitializeParameter/enums), RoomRuleSetting,
VideoHostingHUD(+HUDMode), TabList, BattleCardView.AttackTargetSelectInfo, ProtectionColorType.
Wired hand shims partial + MonoBehaviour bases; let generated supersede hand-written
nested enums (decomp-authoritative values). SocketManager kept as minimal SDK hand shim.
ClosureStubs for ~14 referenced empties.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:33:00 -04:00
gamer147
b47741d2a5 feat(battle-engine): full-surface god-object stubs (UIManager/GameObjMgr/BattleLog) 7532->5600
Generate the COMPLETE decomp member surface (not frontier-subset, which silently drops
already-provided members) for UIManager(+ViewScene/ChangeViewSceneParam), GameObjMgr,
BattleLogManager/Item, InPlayCardFrameEffectControl. UIManager/Footer base fixes:
UIManager is MonoBehaviour (singleton kept by hand via --exclude); Footer is the copied
Engine type (removed the conflicting global shim). Add HUDMode enum, Wizard manager
return-type stubs, and a closure-stubs file for 7 referenced empties.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:23:52 -04:00
gamer147
de1b7362c9 feat(battle-engine): BattleLog cluster via generated no-op stubs (7852->7532)
Stub-generate BattleLogManager(45)/BattleLogItem(17)/InPlayCardFrameEffectControl(4)
member surfaces from decomp signatures; declare BattleLogWindow+nested enum; make
BattleLogItem a MonoBehaviour so inherited gameObject/transform resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:05:57 -04:00
gamer147
f9982f5249 feat(battle-engine): copy-loop closure to 3282 files (member-surface frontier) 2026-06-05 20:41:25 -04:00
gamer147
3dcd53933a feat(battle-engine): AOT/SFB/Steam/DisallowMultiple + FriendDataBase.SetPlayerData
Clears the last type+header frontier (RoomInviteFriendColum override). Per F3 this
unmasks the remaining View/UI/god-object member bodies (~8k) -- the next grind is
pure member-surface growth, closure (~3242 files) now essentially complete.
2026-06-05 20:40:33 -04:00
gamer147
0455ff649e feat(battle-engine): EffectType full enum + collection/card/vfx extension copies
Replaces partial EffectMgr.EffectType with all 226 decomp values; copies the
IsNotNullOrEmpty/EquelsID/FindFromCardId/GetAllFuncVfxResults extension files +
UI extensions; adds Renderer/MeshFilter shared-material/mesh/sortingOrder. Compile
loop then closed the revealed deps (3242 files). 9.1k -> 18 errors.
2026-06-05 20:38:56 -04:00
gamer147
c3bd39f2cb feat(battle-engine): final type-frontier residual (Story/Title/Friend stubs, SDK anchors, Unity AndroidJavaObject/WebCamTexture)
Clears the last CS0246/CS0234 type frontier; per F3 this unmasks the AI-subsystem
member bodies (~9k member-level errors) -- next grind is extension copies + god-object
member growth.
2026-06-05 20:34:49 -04:00
gamer147
824309ec44 feat(battle-engine): close the AI-simulation subsystem (verbatim)
Copied the 89 uncopied AI*SimulationUtility/extension files defining the
AIVirtualCard/AIVirtualField extension methods; the compile loop then auto-closed
the revealed type deps (~3049 files total, drift-clean). 10.0k -> 62 errors.
2026-06-05 20:30:59 -04:00
gamer147
78f310c2b3 feat(battle-engine): grow god-object + VFX-container shim surface
GameMgr (managers/setting/flags), UIManager (GetInstance + scene/dialog/loading
surface), EffectMgr (Start/effect lifecycle), VfxMgr + VfxWithLoading(Sequential)
register methods -- signatures mirrored from decomp. 15.9k -> 10.0k errors.
2026-06-05 20:27:00 -04:00
gamer147
4491c6c7f3 feat(battle-engine): full Unity primitive/runtime surface + game extension copies
Grows Vector2/3, Mathf, Color, Quaternion, GameObject, Transform, Camera, Material,
ParticleSystem, Rect, Bounds, Time to the surface the engine references; adds Input/
Random/Resources statics + full KeyCode enum. Copies the verbatim extension files that
collapse thousands of CS1061s at once (ContentKeywordExt, JsonData/LitJson extensions,
EventExtension.Call, GameObjectExtension(s)). 26.5k -> 15.9k errors; residual now
dominated by god-object member surface (GameMgr/UIManager/EffectMgr/Vfx*).
2026-06-05 20:22:43 -04:00
gamer147
a00e90c74a feat(battle-engine): clear header frontier (Item/ErrorDialog/SDK shims + infra copies)
Resolves the 268-error header frontier: settings Item base, ErrorDialog.Data,
RoomConnectController nested types, Unity asset/light/collider types, CriWare/
CodeStage/Spine SDK surface, and copies INetworkLogger + SingletonMonoBehaviour
verbatim. Per F3 this unmasks the type bodies (~26.5k member-level errors now
visible) -- the real M1 bulk, attacked in following waves.
2026-06-05 20:11:08 -04:00
gamer147
957af3d1ec feat(battle-engine): full Unity/VFX/god-object shims + expanded copy closure (2570 files)
Authored Unity primitive/object-model shim, VFX layer (control-flow-preserving, InstantVfx never invokes its action -- headless suppression), god-object stubs (GameMgr/EffectMgr/UIManager with faithfully-extracted nested enums), View/UI/Touch tree, LitJson+BetterList+Tuple copied, third-party stubs. Discovered Roslyn header-error masking: fixing class-header type errors unmasks body references, so the true copy closure is ~2570 files (was 782 under masking). Errors: masked-25720 -> 268; our shim files compile clean. Remaining: ~50 residual shim/external types, 24 NGUI UI-base overrides, static-type fixes, plus likely 1-2 more unmask waves.
2026-06-05 17:22:20 -04:00
gamer147
0d9d8acae0 feat(battle-engine): M1 auto-copy closure (782 battle-logic files)
Compile-driven bulk-copy loop (tools/engine-port/m1_copy_loop.py) pulled the precise reference closure of the battle-core roots, stopping at the classify god-object/View-VFX-UI boundary. 782 files; no re-explosion (M0 had estimated ~order 1000). Residual frontier = 52 shim-classified + 80 external (Unity/BCL) types to author next.
2026-06-05 16:57:20 -04:00
gamer147
23a6596558 fix(battle-engine): Quaternion.identity w=1 to match Unity semantics 2026-06-05 16:49:02 -04:00
gamer147
550cedbf1e feat(battle-engine): seed copy roots + UnityEngine primitive shim
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:45:12 -04:00
gamer147
bb80815b01 feat(battle-engine): scaffold empty SVSim.BattleEngine library 2026-06-05 16:34:46 -04:00
gamer147
13f902ce58 fix(battlenode): emit real spellboost count in played-card knownList
The node hardcoded knownList.spellboost=0 on every played card. Prod sends
the true accumulated count, which the client reads straight into the card's
cost model; with 0 the opponent computes the card at full price and silently
rejects the play in OperateReceiveChecker.IsPlayCard (PP-over -> ConductError
-> NullOperationCollection -> no render/echo), desyncing the board.

Mine spellboost-count changes from the sender''s orderList alter ops
(MineAlterSpellboosts: a/s/h ops), accumulate per-side idx->count in
BattleSessionState (RecordSpellboostFrom), and surface the current count on
the played card via BuildPlayedCard. Recorded from the authoritative
PlayActions only (never the Echo) and folded in AFTER the played card is
built, since a card''s cost is fixed as it leaves hand and a play that grants
spellboost targets the rest of the hand.

Also adds a [sio-in-body] full-body inbound log to RealParticipant to capture
both clients'' re-simulated responses for PvP RNG verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 13:51:40 -04:00
gamer147
2d32051cc0 refactor(battlenode): key dispatch on OpponentIsAckOnly, drop per-frame BattleType switch
Behavior-identical; 231 BattleNode tests green with ZERO test changes.

The 10 handler arms no longer switch on BattleType:
- 4 Bot arms gate on the new FrameDispatchContext.OpponentIsAckOnly
  (Other is not IHasHandshakePhase) — the participant property the audit asked for.
- 6 relay arms drop the Type == Pvp guard; it was redundant with BothSidesAfterReady()
  (only a two-real-player session has both handshake phases). Its doc now records that.
- FrameDispatchContext.Type removed (+ the Type = Type in BuildContext). BattleSession.Type
  stays for the session-level drop cascade.

Zero test churn because the stubs already encode the split: FakeRealParticipant/ProbeParticipant
implement IHasHandshakePhase, the bot stub FakeParticipant doesn't, and NewBotSession uses it as
the opponent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:20:56 -04:00
gamer147
9ff8948903 docs(battlenode): document four latent low-tier hygiene hazards
Comment-only; behavior-preserving; 231 BattleNode tests green.

- OutboundSequencer._archive: name the unbounded-per-match growth + ack-prune point.
- NodeCrypto.BuildAes: SECURITY remarks on key-derived IV reuse + base64 entropy loss;
  warn against caching the session key.
- MatchContext/BattlePlayer: FOOTGUN notes on reference-based record equality over the deck list.
- RecordTokensFrom: TRUST note on isSelf/idx overwrite; name the idx>deckCount guard for
  untrusted peers (not added — trusted-LAN today).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:11:13 -04:00
gamer147
1007cf24d2 refactor(battlenode): type MatchContext.ClassId as CardClass enum (§C)
Behavior-preserving; full solution builds, 1013 tests green.

ClassId is the one genuinely-closed set of the three flagged stringly fields, so it
becomes a CardClass enum (1..8). Wire stays "1".."8": producer casts
(CardClass)run.ClassId, ServerBattleFrames renders via CardClassWire.ToWireValue().
RankBattleController's AI-start path drops a fragile int.TryParse(...)?:-1 for (int)cast.

CharaId (free-form leader/skin id, e.g. "5000123") and CountryCode (open-ended account
data) stay string with proper XML docs; CountryCodes.Korea/Japan name the captured values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:04:49 -04:00
gamer147
9b8a7f1e37 refactor(battlenode): name sender-only vs both-sides handshake checks (§D)
Behavior-preserving; 231 BattleNode tests green.

FrameDispatchContext.BothAfterReady() -> BothSidesAfterReady() (7 call sites). The
4 inline `SenderPhase == AfterReady` checks in TurnEndHandler/TurnEndFinalHandler now
read a new SenderIsAfterReady property. Both carry cross-referencing docs so the
Bot-arm (sender-only) vs PvP-arm (both-sides) distinction is explicit at the type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:49:27 -04:00
gamer147
578d0a75ef refactor(battlenode): rename mode-id field off BattleType, add BattleModes (§D)
Behavior-preserving; 271 BattleNode/Matching/Services tests green, full solution builds.

"BattleType" meant two things: the Sessions.BattleType enum (Pvp/Bot) and an int
"mode id" field. Renamed the int field on MatchContext AND the BattleStartBody wire
DTO to BattleModeId (wire key stays "battleType" via JsonPropertyName), so BattleType
now means only the enum project-wide.

New Bridge/BattleModes.cs (TakeTwo = 11) replaces every 11 literal — both prod
MatchContextBuilder sites and the test fixtures/assertions. The arbitrary-passthrough
42 and bot 0 stay literal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:44:02 -04:00
gamer147
d119d2c277 refactor(battlenode): single-source MsgEnvelope envelope keys (§E)
Behavior-preserving; 231 BattleNode tests green.

The envelope key set was encoded three times (ReservedEnvelopeKeys, the ToJson
writes, the FromJson reads). Added a private nested MsgEnvelope.Keys with a const
per key; the reserved set, writes, and reads now all draw from it, so a key added
in one place but not another (letting a body key shadow an envelope field) can no
longer happen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:33:54 -04:00
gamer147
7e167b1cef refactor(battlenode): centralize inbound wire-key literals in WireKeys (§C)
Behavior-preserving; 231 BattleNode tests green (capture-conformance suite drives
real prod frames, so a wrong constant would fail).

New Sessions/Dispatch/WireKeys.cs holds the 28 inbound-body read keys (orderList /
keyAction / targetList / uList field names). KnownListBuilder, PlayActionsHandler,
EchoHandler, and BattleFrames.ExtractIdxList now read through it instead of repeated
inline strings, so a parse-side typo ("isSelf" vs "IsSelf") can no longer silently
degrade token resolution. Outbound [JsonPropertyName] attributes left as-is (already
single-source per DTO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:30:02 -04:00
gamer147
3e8901eec3 refactor(battlenode): split BattleSessionPhase into HandshakePhase + SessionLifecycle
Behavior-preserving; 231 BattleNode tests green.

One enum conflated two axes. Split:
- HandshakePhase (per participant): AwaitingInitNetwork..AfterReady. On
  IHasHandshakePhase.Phase, FrameDispatchContext.SenderPhase, the handler gates.
- SessionLifecycle (per battle): Active | Terminal. On the renamed
  BattleSessionState.Lifecycle (was SessionPhase, defaulting to a handshake value)
  and BattleSession.Lifecycle (was Phase). Reads are only != Terminal, so the
  Active default is behavior-identical.

OpponentTurn was dead (never assigned) -> dropped. BattleSessionPhase deleted; the
two axes can no longer be cross-assigned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:21:59 -04:00
gamer147
7d4da69f22 refactor(battlenode): low-churn §B/§D/§E/§F quality cleanups
Behavior-preserving; 231 BattleNode tests green.

- §D: MsgEnvelope.Try -> RetryAttempt (drops keyword-escape; wire key stays "try");
  SocketIoFrame.AckResponse arg -> pubSeqEcho.
- §B: Gungnir.EmitInterval -> BattleNodeOptions.AliveEmitInterval (unused literal
  moved to its config home); deck-idx 4L -> InitialHand.Length + 1.
- §E: shared Wire.WireJsonOptions.CamelCase replaces the duplicated camelCase
  JsonSerializerOptions in EngineIoHandshake and MsgEnvelope.
- §F: do-NOT-consistency-fix polarity notes on TurnEndFinalHandler (From wins)
  and RetireKillHandler (From loses).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:06:44 -04:00
gamer147
e70f32db79 refactor(battlenode): close §A boolean-blindness items (MinedToken, Stock, KeyActionType)
Behavior-preserving; 231 BattleNode tests green.

- MinedToken record struct replaces the transpose-prone (int Idx, long CardId,
  CardOwner IsSelf) tuple returned by KnownListBuilder.Mine*. Positional deconstruct
  keeps the Record*From call sites unchanged.
- enum Stock { Normal, Bypass } replaces the negative `bool noStock` on
  IBattleParticipant.PushAsync and DispatchRoute, threaded through both participants,
  BattleSession, and all handler construction sites.
- enum KeyActionType mirrors the client's SendKeyActionDataManager.KeyActionType;
  the StripKeyActionForOpponent guard compares named values, KeyActionEntry.Type is
  the enum (wire-identical via JsonNumberEnumConverter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:53:32 -04:00
gamer147
a3e445cf2f refactor(battle-node): replace int IsSelf with CardOwner enum on mined-token tuples
MineAddOps/MineChoicePicks/MineCopyTokens return types and all
extraction casts changed from int to CardOwner. The 4 routing
comparisons in BattleSessionState now read isSelf == CardOwner.Self
instead of isSelf == 1.

No wire or behavioral change — CardOwner was already in use on the
wire-facing side (OppoTargetEntry, UnapprovedCardEntry); this extends
it to the internal mining path so the bare-int transpose risk is gone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 22:25:01 -04:00
gamer147
564b1d678f fix(battle-node): collision-safe battle-id registration + viewer eviction
RegisterPending → TryRegisterPending (TryAdd instead of indexer) so
battle-id collisions return false instead of silently evicting a live
battle. MatchingBridge retries with fresh IDs on collision (max 5).

Before registering, EvictStaleForViewer removes any stale pending
battle the viewer left behind, enforcing the one-pending-per-viewer
invariant that was previously comment-asserted.

Store tests switched to per-test local stores to fix a race under
the assembly-wide ParallelScope.All.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 22:13:20 -04:00
gamer147
c6fb411861 fix(battle-node): dispose participants, unsubscribe events, filter catch
#5: BattleSession.RunAsync now unsubscribes FrameEmitted handlers
(-= OnFrameFromA/B) before termination and calls DisposeAsync on
both participants + the dispatch gate SemaphoreSlim afterward. This
unpins the session state from live delegates and releases the WS.

#6: Bare catch {} blocks replaced with filtered exception handlers
that silently swallow OperationCanceledException and WebSocketException
(expected at battle end) but log anything else at Warning. NREs and
other real bugs in handler threads are now visible instead of silently
eaten.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 22:00:28 -04:00
gamer147
99129c786c fix(battle-node): harden SIO parse + narrow Matched OppoId/Seed to int
#3: SocketIoFrame.Parse now range-checks the packet type char (was
unchecked cast — any char outside 0-6 produced an undefined enum
value) and uses int.TryParse for ack-id (was int.Parse — a >10-digit
ack-id threw OverflowException, tearing down the WS mid-game). Both
now throw ArgumentException consistently. The read loop in
RealParticipant wraps both EIO and SIO parse calls with try-catch so
a malformed frame is logged and skipped instead of killing the battle.

#4: MatchedSelfInfo/MatchedOppoInfo OppoId and Seed narrowed from
long to int. The client reads both with Convert.ToInt32 inside a
swallowing try/catch — any value > int.MaxValue silently dropped the
Matched event, preventing the battle from starting. Seed was already
int-range (BattleSeeds.Stable returns int); OppoId (viewer ID) is
~847M in captures, well under int.MaxValue. The narrowing cast now
happens explicitly in ServerBattleFrames.BuildMatched at the wire
boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 21:57:29 -04:00
gamer147
e9af7af1b8 fix(ranked-ai): randomize bot selection and seed for AI fallback matches
Bot roster pick was hashing (UserName, ClassId) — same player always
faced the same bot class. Now hashes battleId so different matches get
different opponents while retries of the same pending battle stay
consistent.

AI start response hardcoded Seed=0 for both sides, so the client's
deck shuffle/mulligan/draw RNG was deterministic every match. The
BattleNode's per-battle MasterSeed (Random.Shared) was never sent to
bot-mode clients because InitBattleHandler skips the Matched frame.
Now populates Seed with Random.Shared.Next() on the HTTP response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 21:49:43 -04:00
gamer147
77c99cc230 fix(battle-node): serialize per-session dispatch to stop cross-thread state race
In PvP a BattleSession subscribes to both participants' FrameEmitted, and each
RealParticipant raises it from its own WebSocket read loop -- two threads. The
dispatch path (ComputeFrames + the relay PushAsync calls) mutates shared,
non-thread-safe state: the BattleSessionState dictionaries (deck maps, post-swap
hands, idx->cardId reveal map). Concurrent frames from both players could corrupt
those dictionaries (InvalidOperationException / torn playSeq / wrong card identity).

Add a per-session SemaphoreSlim _dispatchGate around the whole HandleFrameAsync so
both read loops funnel through one critical section. ComputeFrames stays lock-free
(the direct-call test seam is single-threaded).

Analysis during the fix showed each OutboundSequencer is single-writer-per-instance
in steady state (A's loop only writes B's Outbound and vice-versa), so the live race
is the shared BattleSessionState, which the gate fully serializes.

TDD: BattleSessionDispatchConcurrencyTests drives both participants to AfterReady,
then fires TurnStart from both at once; the target PushAsync records peak in-flight
dispatches. Red (MaxConcurrent=2) before the gate, green (1) after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:00:41 -04:00
gamer147
24180d5b4b refactor(battle-node): de-magic wire flags and scattered constants
Quality pass from the 2026-06-04 BattleNode review (audit in the outer
repo). All changes are behavior-preserving — identical wire bytes,
verified by the full 1008-test suite staying green.

- Name scattered magic numbers: crypto key/IV lengths, outbound-sequencer
  base, WS receive buffer / EIO ping / SID length, polite-close timeout,
  upgrade-credential keys, battle-id digit math, deterministic-turn spin.
- resultCode = 1 -> (int)ReceiveNodeResultCode.Success across body records.
- Pong "3" -> EngineIoPacketType.Pong; remove dead NoOpBotParticipant.Touch
  (replace with #pragma warning disable CS0067).
- Wire-flag enums, serialized as numbers via JsonNumberEnumConverter:
  turnState -> TurnState{First,Second}, isSelf -> CardOwner{Opponent,Self},
  open -> ChoiceVisibility{Hidden,Open}.
- isOfficial / isInvoke -> bool / bool? via new NumericBoolJsonConverter
  (reads/writes 0/1; TDD'd). Scoped to the BattleNode wire boundary only;
  MatchContext and the HTTP/AI-start path stay int (AI-start uses -1 as a
  sentinel, so it is not boolean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:46:09 -04:00
gamer147
ed88683fa0 merge: per-battle master seed + node-side deck shuffle
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:25:13 -04:00
gamer147
b229885259 refactor(battle-node): retire hardcoded BattleSeed + ReadyIdxChangeSeed
Both now derive per-battle from the master seed via BattleSeeds; only
animation/UI constants (ReadySpin, rank/battlePoint placeholders) remain in
BattleFrameDefaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:21:28 -04:00
gamer147
3f5d97cb2f feat(battle-node): derive Matched.seed + Ready.idxChangeSeed from master seed
InitBattle now emits Stable(master) as the shared effect seed and the master-
shuffled deck as selfDeck; Swap emits each recipient's per-side IdxChange seed.
BattleSession exposes + logs the master seed per battle for future replay.
Updated lifecycle/dispatch/integration tests (deck assertions now permutation-
based since selfDeck is shuffled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:20:51 -04:00
gamer147
6f7fcfe28e feat(battle-node): per-battle master seed + node-side deck shuffle
GetOrSeedDeckMap now seeds from a Fisher-Yates shuffle of the deck keyed by the
per-battle MasterSeed, so the reveal map and the wire selfDeck share one
shuffled order. Updated the existing build-order test to the shuffle semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:14:14 -04:00
gamer147
11c98bf67b feat(battle-node): BattleSeeds — stable per-battle seed derivation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:13:06 -04:00
gamer147
75f3d8ea5b revert(battle-node): remove real-spin logic (CountHiddenDraws + per-frame spin)
Two-sided capture (data_dumps/captures/battle_test/rng, 2026-06-04) showed the
receiver already reproduces uList-relayed deck fetches (Hoverboard) and turn
draws on its own shared stream, so the emitted spin=1 double-cranked and desynced
the clients by 1. Residual spin is ~0 for the current card pool. Reverts 63cb324
and 617714e; back to the prior correct spin:0 behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:07:08 -04:00
gamer147
617714ebea feat(battle-node): emit real spin per-frame on forwarded PlayActions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:13:47 -04:00
gamer147
63cb3248b4 feat(battle-node): CountHiddenDraws — hidden shared-RNG draw tally for real spin
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:13:47 -04:00
gamer147
56652c7034 fix(battle-node): expand rank-battle deck by DeckCard.Count
BuildForRankBattleAsync projected deck.Cards.Select(c => c.Card.Id),
discarding Count. DeckCard is count-based (one row per unique card +
a Count), so a 3-copy card shipped to the node as a single in-battle
card -- matched decks showed 1 of each card instead of the real count.

Expand each row by its Count so SelfDeckCardIds carries one entry per
physical card. TwoPick path is unaffected (flat per-pick list).

Add a regression test seeding 3+2+1 copies (failed Expected 6/was 3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:09:14 -04:00
gamer147
7bd2c0f2d7 test(battle-node): lock relayed uList shape vs prod recv capture (line 75)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:20:03 -04:00
gamer147
a0aa58cfbe feat(battle-node): relay uList on PvP PlayActions
Forwards the sender's deck-sourced summons/fetches to the opponent
(closes the spin-independent slice of direct-to-field summons). uList
coexists with the synthesized knownList in the same frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:18:20 -04:00
gamer147
c0309061fa feat(battle-node): UnapprovedCardEntry + RelayUList pure transform
Verbatim uList relay shape + transform (deck-sourced summons/fetches),
mirroring RenameTargets. Not yet wired into the handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:17:10 -04:00
gamer147
61080adace test(battle-node): lock copy-op parse vs prod capture line 196
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:12:20 -04:00
gamer147
b6edfbcf15 feat(battle-node): reveal copy tokens on play via baseIdx resolution
PlayActionsHandler + EchoHandler now call RecordCopyTokensFrom (ordered
after plain/choice mining) to resolve a copy add's baseIdx against the
side's live idx->cardId map and record copyIdx->cardId. A copy played in a
later (or same) frame synthesizes a knownList instead of degrading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:11:34 -04:00
gamer147
f9c7e6124b feat(battle-node): resolve copy-token cardIds from baseIdx (pure)
KnownListBuilder.MineCopyTokens resolves a copy add's baseIdx against the
actor's own idx->cardId map (self/other by isSelf), yielding (idx,cardId,
isSelf). Skips concrete/choice adds, string (private-group) baseIdx, and
unknown sources (degrade). Third token-reveal slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:09:36 -04:00
gamer147
5c3835f4fd feat(battle-node): reveal choice/Discover tokens to opponent
Choice/Discover-into-hand fanfares add a candidates-only token to hand; the
chosen cardId rides keyAction.selectCard on the generating play, not the
orderList add op. Record idx->chosenCardId at generation (candidate-membership
join) so the later play reveals the real identity via the existing
BuildPlayedCard path; forward {type,cardId} to the opponent and strip
selectCard for hidden (open:0) picks (pass through for open:1, provisional).

- KnownListBuilder.MineChoicePicks + StripKeyActionForOpponent (pure)
- BattleSessionState.RecordChoicePicksFrom (reuses IdxToCardId, no new state)
- PlayActionsBroadcastBody.keyAction + KeyActionEntry/SelectCardEntry
- PlayActionsHandler wires both; EchoHandler unchanged (picks ride the send)

Tests (TDD red->green): 8 KnownListBuilder + 2 dispatch + 2 conformance
(shape-locked to tk2_regular L151 generation / L193 reveal). Full suite 976/0.

Spec: docs/superpowers/specs/2026-06-04-battle-node-choice-token-reveal-design.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:53:48 -04:00
gamer147
62251482e4 feat(battle-node): cross-side gift + Echo-frame token mining
Close the two generated-token gaps that desynced PvP live test #3 (the
Forestcraft Fairy), both sourced from the 2026-06-03 decomp-validation table.

- MineAddOps now returns (idx, cardId, isSelf) and no longer drops isSelf:0.
  isSelf is the sender's perspective tag on CardObj.IsPlayer (RegisterToken.cs:22)
  and a card has one CardObj.Index, so an isSelf:0 add is the opponent's card.
- New shared BattleSessionState.RecordTokensFrom routes isSelf:1 -> sender,
  isSelf:0 -> opponent (the gift lives in the recipient's map, consulted when
  they play it). PlayActionsHandler delegates to it.
- EchoHandler now mines via the same helper but still returns no routes. An
  Echo's orderList carries the same add-op shape as a send (MakeEchoData ->
  MakeCommonSendAndEchoCardData), so MineAddOps applies verbatim; mining != relaying.

Choice/copy/private-group adds stay skipped (no concrete cardId). Full solution
963/963 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 07:59:46 -04:00
gamer147
155ccf0a48 test(battle-node): lock token-reveal knownList shape vs prod capture line 96
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:41:59 -04:00
gamer147
d8b5ef950d feat(battle-node): reveal generated tokens on play via remembered identity
PlayActionsHandler mines add ops into BattleSessionState.RecordToken each
frame; a token played in a later frame now synthesizes a knownList from the
remembered cardId instead of degrading. Bullet-3 audit F1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:36:44 -04:00
gamer147
b6af8bfb7d feat(battle-node): mine generated-token cardIds from orderList add ops
KnownListBuilder.MineAddOps extracts (idx,cardId) from isSelf:1 add ops,
skipping cross-side gifts and choice tokens. Bullet-3 audit F1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:30:47 -04:00
gamer147
4b38a9d3e0 test(battle-node): rename ScriptedBotCtx test helper to FakeOpponentCtx
Pure private-helper rename in the two lifecycle test fixtures for lexical
hygiene — matches the kept ServerBattleFrames.FakeOpponentViewerId. The
fixture is a fake opponent MatchContext, never a "scripted bot". No behavior
change; both fixtures green (20/20).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:57:58 -04:00
gamer147
ac78e809cd refactor(battle-node): clear residual scripted-bot prose from comments/docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:52:41 -04:00
gamer147
ba18790156 refactor(battle-node): rename ScriptedLifecycle->ServerBattleFrames, ScriptedProfiles->BattleFrameDefaults
Pure rename. These hold the shared server-authored frame builders used by every
battle mode's handshake/mulligan dispatch — the 'Scripted' name was a historical
accident that hid the PvP/Bot crossover. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:36:32 -04:00
gamer147
e9493e24c4 refactor(battle-node): drop BattleType.Scripted and the scripted-only builders
Removes the Scripted enum value, the bot's client-shaped emissions (BuildClient*),
the canned opponent turn (BuildOpponent*), and OpponentTurnStartSpin. The shared
server-frame builders (Matched/BattleStart/Deal/Swap/Ready + ComputeHandAfterSwap)
and OpponentJudgeSpin (Bot mode) stay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:27:57 -04:00
gamer147
b0e3783757 refactor(battle-node): drop dead MatchingResolver options param; fix stray BOM
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:23:57 -04:00
gamer147
f21ab7a38c refactor(battle-node): remove ScriptedBotParticipant and dev-affordance wiring
Deletes the scripted opponent and every entry point that created a
BattleType.Scripted session (the ?scripted=1 query opt-in, the
SoloDefaultsToScripted toggle, the resolver short-circuit, the WS handler case,
the bridge validation arm). Real two-client PvP and the Bot matchmaking-timeout
fallback are untouched. ResolveAsync drops its scriptedOptIn parameter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:15:48 -04:00
gamer147
8085119439 refactor(battle-node): tidy residue after scripted dispatch-arm removal
Remove the now-unused SVSim.BattleNode.Lifecycle using from
FrameDispatchContext (it was only needed for ScriptedLifecycle inside
the deleted IsScriptedBot helper) and reword the SenderPhase doc comment
so it no longer references the removed dispatch-test scripted-bot stub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:06:25 -04:00
gamer147
ca9ad5db8f refactor(battle-node): remove scripted-bot test-stub arms from dispatch handlers
The IsScriptedBot(ctx.From) forwards in JudgeHandler/TurnStartHandler/TurnEndHandler
and the 'if Type==Scripted' raw-forward only ever fired for ScriptedBotParticipant
emissions; NoOpBot (Bot mode) never emits, so they are dead. Routing is now purely
PvP-vs-Bot. Drops the IsScriptedBot helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:00:57 -04:00
gamer147
963adbbd1b test(battle-node): delete scripted participant + scripted-only builder tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:55:00 -04:00
gamer147
3fe378d801 test(battle-node): drop scripted dispatch tests; retarget generic fixture to PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:49:49 -04:00
gamer147
3ccd986e65 test(battle-node): drop scripted smoke test; retarget deck-plumbing test to PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:42:20 -04:00
gamer147
3feb535072 test(battle-node): drop dead ViewerId const + refresh stale coverage doc
Follow-up cleanup to the two-client PvP conformance drive. The class-level
ViewerId const is no longer referenced (both remaining `ViewerId:` sites are
the MsgEnvelope named ctor arg, passing `vid`/literal 1), and the Coverage
doc-comment still described "a single Scripted session" — refresh it to the
two-client PvP reality. No behavior change; tests 2/2 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:37:29 -04:00
gamer147
a916afe924 test(battle-node): drive the conformance oracle via two-client PvP
The golden-match oracle harvested all ten server-authored frames from a single
Scripted client. Re-point it at a two-client PvP session (same shared builders
for handshake/mulligan, real turn-cycle frames for TurnStart/TurnEnd/Judge) so
the oracle survives removal of the scripted bot. Category-based shape check is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:33:58 -04:00
gamer147
3b6b8d3c94 Merge: BattleNode deterministic-turn translator (vanilla PvP slice)
Per-URI PvP frame translator + live-validated TurnEnd<->Judge handover.
Full vanilla two-client match plays end-to-end (card plays, combat, evolves,
fanfares) synced through BattleFinish. 990/990 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:57:55 -04:00
gamer147
e98bd10dbe fix(battle-node): reflect PvP Judge back to its sender (turn handover)
Live two-client run (data_dumps/captures/battle_test) exposed a turn-handover
stall: ending a turn on client A made BOTH clients show A's turn again; the
opponent never got a turn. Root cause: JudgeHandler routed the {spin:0} Judge to
ctx.Other. The client rule is 'receive opponent TurnEnd -> SendJudge', so the
PASSIVE player (the one taking over the turn) is the Judge sender, and 'receive
Judge -> ControlTurnStartPlayer' starts the RECEIVER's turn. Routing to ctx.Other
delivered the Judge to the player who had just ended their turn, restarting it in
a closed loop while the taker-over sat on 'Opponent's Turn'.

Fix: the PvP Judge {spin} reflects back to ctx.From (the sender / turn taker-over),
matching the Bot arm's existing 'Judge to sender only' handover. The sender then
emits TurnStart, which relays to the opponent as {spin}. Updated the dispatch unit
test and the PvpHandshakeAndGameplay integration test to the real handover order
(passive sends Judge -> receives it back -> sends TurnStart -> opponent sees it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:45:17 -04:00
gamer147
c360d639f2 refactor(battle-node): address final-review minor notes (comments + test backfill)
- PlayActionsHandler doc: drop the phantom 'with a debug log' (handlers are
  stateless singletons with no logger); say token plays degrade silently.
- KnownListBuilder.ExtractMoveTo doc: note first-match-wins semantics and the
  send-side==recv-side 'to' assumption pending recv-capture confirmation.
- KnownListBuilderTests: add multi-move first-match coverage and the
  in-deck-but-no-matching-move null branch for BuildPlayedCard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:26:07 -04:00
gamer147
bca94648f7 test(battle-node): update PvpHandshakeAndGameplay to deterministic-turn translator contract
The end-to-end PvP gameplay test asserted the pre-translator relay contract
(A's TurnEnd broadcasts TurnEnd+Judge to both sides). Tasks 7/8 replaced that
with the per-URI translator: the active player ends its turn by sending TurnEnd
then Judge, the opponent receives the translated {turnState:0}/{spin:0} frames,
and the sender receives nothing. Rewrote the gameplay section to drive and
assert the new contract. PlayActions remains delivered to the opponent (Uri
preserved, body now synthesized by PlayActionsHandler).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:21:01 -04:00
gamer147
f0026972cb test(battle-node): ground synthesized knownList shape against prod recv capture
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:13:42 -04:00
gamer147
f9c671c089 feat(battle-node): TurnEndActionsHandler emits empty body to opponent in PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:11:41 -04:00
gamer147
58994a53c9 feat(battle-node): JudgeHandler emits {spin:0} to opponent in PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:09:44 -04:00
gamer147
3c8a00c928 feat(battle-node): TurnEndHandler emits {turnState:0} to opponent only in PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:07:44 -04:00
gamer147
6e85a6b2db feat(battle-node): TurnStartHandler emits {spin:0} to opponent in PvP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:05:15 -04:00
gamer147
6b580c622d feat(battle-node): EchoHandler consumes Echo instead of relaying
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:03:19 -04:00
gamer147
506d286529 feat(battle-node): PlayActionsHandler synthesizes knownList (vanilla deck-card slice)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:59:54 -04:00
gamer147
030d3b8057 feat(battle-node): KnownListBuilder pure transforms (knownList synth, target rename)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:56:12 -04:00
gamer147
b295fd8f09 feat(battle-node): per-side idx->cardId map on BattleSessionState
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:53:32 -04:00
gamer147
486f72f4a0 feat(battle-node): typed PlayActionsBroadcastBody + KnownCardEntry/OppoTargetEntry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:51:02 -04:00
gamer147
268b864e28 refactor(battle-node): delete legacy ComputeFrames switch; dispatch is now lookup-or-drop 2026-06-03 14:48:33 -04:00
gamer147
503c382646 refactor(battle-node): extract ForwardWhenBothReadyHandler; share handler instances via BuildHandlers 2026-06-03 14:33:26 -04:00
gamer147
db2f711894 refactor(battle-node): extract JudgeHandler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:30:40 -04:00
gamer147
aacd7b56ad refactor(battle-node): extract TurnStartHandler
Unions the two legacy TurnStart arms (IsRealForwardableFromScripted case 11 +
BothAfterReady case 12) into TurnStartHandler. Both arms produce (Other, Env, false)
with no extra guards or state mutations — union is behavior-equivalent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:27:17 -04:00
gamer147
c03fb3c139 refactor(battle-node): extract RetireKillHandler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:24:35 -04:00
gamer147
d35818360f refactor(battle-node): extract TurnEndFinalHandler 2026-06-03 14:21:54 -04:00
gamer147
538099ff4b refactor(battle-node): extract TurnEndHandler 2026-06-03 14:20:25 -04:00
gamer147
477faf3df3 refactor(battle-node): extract SwapHandler (mulligan barrier) 2026-06-03 14:13:26 -04:00
gamer147
3e2931b085 refactor(battle-node): extract LoadedHandler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:10:33 -04:00
gamer147
e5ec8a0de1 refactor(battle-node): extract InitBattleHandler 2026-06-03 14:07:49 -04:00
gamer147
7c36933c06 refactor(battle-node): extract InitNetworkHandler 2026-06-03 14:04:58 -04:00
gamer147
73d2c4e1b8 refactor(battle-node): add frame-handler contract, context, and empty registry shim 2026-06-03 14:03:11 -04:00
gamer147
57d91236a0 chore: ignore *.bak editor backups 2026-06-03 13:56:59 -04:00
gamer147
4f89463f9c refactor(battle-node): extract frame factories into BattleFrames 2026-06-03 13:56:41 -04:00
gamer147
85c43a9a72 refactor(battle-node): move session phase + post-swap hands into BattleSessionState 2026-06-03 13:47:35 -04:00
gamer147
95554cee04 refactor(battle-node): name ComputeFrames routes as DispatchRoute 2026-06-03 13:43:39 -04:00
gamer147
afe2984075 test(battle-node): drive PvP flow handshakes through the mulligan barrier
The three PvP BattleNodeFlowTests drove each client's handshake to Ready
independently; the new barrier withholds Ready until both sides swap, so the
single-client helper timed out. Split DriveHandshakeAsync into DriveThroughSwapAsync
(stops at SwapResponse) + DrivePvpHandshakeAsync (drives both, then drains the
barrier-released Ready for each). Scripted/Bot single-client paths are unaffected
(non-IHasHandshakePhase opponent releases Ready immediately).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:58:33 -04:00
gamer147
feb387d3d5 test(battle-node): real scripted bot drives handshake through the mulligan barrier
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:53:51 -04:00
gamer147
2d31037648 fix(battle-node): type-agnostic mulligan barrier withholds Ready until both swap
Ready was sent per-side immediately carrying the placeholder opponent hand, so
one client cleared mulligan before the other. The barrier now releases Ready to
every IHasHandshakePhase participant only once all have swapped, each carrying
the opponent's real post-mulligan hand. No Type check — NoOp (Bot/AINetwork)
isn't a phase impl, so that mode still releases immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:52:33 -04:00
gamer147
8052ed60ec refactor(battle-node): scripted bot drives the handshake as a real participant
Implements IHasHandshakePhase and emits client-shaped InitNetwork/InitBattle/
Loaded/Swap (reacting to the session's pushes) instead of being a passive
TurnEnd-only fixture the session narrates around. This is what lets the
type-agnostic mulligan barrier (next task) work in Scripted mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:51:08 -04:00
gamer147
a533e9d89d feat(battle-node): client-shaped handshake builders for the scripted bot
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:49:38 -04:00
gamer147
633c29b44f feat(battle-node): BuildReady overload carrying the opponent's hand
Adds BuildReady(selfHand, oppoHand) for the mulligan barrier; the single-arg
overload keeps the InitialHand placeholder for non-interactive opponents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:48:43 -04:00
gamer147
ae11fe0957 fix(battle-node): assign turnState per side instead of hardcoding 0
Both PvP clients received turnState:0 ('both go first'). BuildBattleStart
now takes turnState; the Loaded arm assigns 0 to A, 1 to B — no Type check,
correct in Scripted (real player = A = first) and PvP (first arriver first).

Updated three existing BuildBattleStart callers in the test suite to pass
turnState:0 (the param is now required).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 10:47:56 -04:00
gamer147
84ed07d3af chore(dev): enable Steam-ticket bypass + PvP matching for local smoke 2026-06-03 09:09:23 -04:00
gamer147
feaa149f04 feat(auth): select ISteamServer impl by Auth:BypassSteamTicket config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:07:51 -04:00
gamer147
c27bf444a5 refactor(auth): drop null-guard on dev steam ticket log; add test fixture doc
ISteamServer contract forbids null tickets (prod impl and sole caller both assume non-null),
so the dev bypass no longer needs the ?. / ?? 0 defensive form. Also adds a class-level XML
doc summary to DevAlwaysValidSteamServerTests matching the style of other fixtures in the suite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:06:26 -04:00
gamer147
ae94d62357 feat(auth): add Dev-only always-valid ISteamServer for local no-Steam clients
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:02:54 -04:00
gamer147
05d8169012 refactor: type reward_type columns as UserGoodsType enum
Replace bare `int RewardType` on 12 catalog/reward entities and GrantedReward
with the existing UserGoodsType enum. Verified against the decompiled client:
every wire reward_type decodes through the single Wizard.UserGoods.Type enum, so
one enum is correct across all endpoint families (item_type is a separate
Item.Type axis, left untouched). EF stores the enum as the same int column, so
there is no migration.

- Importers cast seed int -> UserGoodsType at the ingest boundary.
- New GrantedReward.ToRewardList() extension replaces 8 copy-pasted
  GrantedReward -> RewardListEntry projections.
- Fix 3 .ToString() sites that would otherwise emit enum names ("Crystal")
  instead of the int wire value ("2").
- Wire DTOs keep int; the enum is widened to int at the wire boundary only.

Build green; 962/962 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:50:49 -04:00
gamer147
fb1e91cdf1 Diagnostic logging 2026-06-02 22:10:18 -04:00
gamer147
c551d7b05e refactor(battle-node): drop dead BattleResult.{Lose,Win,Consistency} members
No dispatch arm has emitted these since the Retire/Kill rewrite to RetireWin=105
/ RetireLose=106. Remove them and the docstring paragraph that explained them.

Test fallout: delete BattleFinishBody_LoseAndConsistency_SerializeAsZeroAndTwo
(its only purpose was locking the dead wire values), and re-point
BattleFinishBody_SerializesResultAndResultCode_AsNumericWireValues at the live
LifeWin=101 so it still guards the JsonNumberEnumConverter numeric-wire behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:17:30 -04:00
gamer147
d76b96b339 test(battle-node): lock server-authored frame shapes against prod captures
Add CaptureConformanceTests: drive one Scripted lifecycle, harvest all ten
server-authored synchronize frames (InitNetwork/Matched/BattleStart/Deal/Swap/
Ready/TurnStart/TurnEnd/Judge/BattleFinish), re-serialize via MsgEnvelope.ToJson,
and diff each against representative prod TK2 capture frames embedded as a
fixture. Comparison is capture-subset-of-ours on body shape (recursive keys +
value category), so missing/miscased/mistyped fields fail but extra envelope
fields we emit don't; pure sequencing keys are excluded.

Because PvP reuses the same ScriptedLifecycle builders for the handshake/mulligan
frames, this transitively locks the PvP handshake shape -- a regression oracle
that outlives the June-2026 server shutdown.

Also replace the stale v1-only README with a pointer to the canonical
docs/battle-node.md hub (outer repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:03:56 -04:00
gamer147
a198174ede fix(battle-node): involuntary-drop survivor gets DisconnectWin, not Win=NoContest
Code-review follow-up to the dispatch unification (0a8a84b).

1. The RunAsync drop cascade synthesized BattleFinish(Win=1), which the client
   renders as RESULT_CODE.NoContest ("battle ended in no contest") instead of a
   win. Add DisconnectWin=201 (already in the client enum, routes to WIN UI) and
   ship it for involuntary opponent drops. Update PvpMidGameDisconnect_FullCascade.

2. Remove BuildBattleFinishNoContest() — dead since the Retire/Kill arm moved to
   RetireWin/RetireLose.

3. Correct the BattleResult docstring: Lose/Win/Consistency are no longer emitted
   by any dispatch arm; they survive only as serialization-test constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:26:16 -04:00
gamer147
0a8a84b2cc refactor(battle-node): unify TurnEndFinal / Retire-Kill / gameplay-forwarder dispatch across types
Three dispatch arms had Type-based branching that was either wrong or
unnecessary. Unified per the audit doc's recommended order, grounded in
verified facts about each participant's PushAsync.

(1) TurnEndFinal — was branched: PvP broadcast TurnEnd+Judge (wrong on a
game-end signal); Scripted pushed BattleFinish(LifeWin). Unified:
  - forward the envelope to other (matches prod TK2 capture
    battle-traffic_tk2_regular.ndjson:273 — loser receives TurnEndFinal
    from server before BattleFinish)
  - push BattleFinish(LifeWin) to from (winner)
  - push BattleFinish(LifeLose) to other (loser)
  - Phase → Terminal

  Requires ScriptedBotParticipant.PushAsync to no longer fire its 3-frame
  burst on TurnEndFinal (previously it reacted to both TurnEnd and
  TurnEndFinal). The dispatch arm now owns TurnEndFinal's response; the
  bot reacting too would race with the BattleFinish push. Bot still
  fires on regular TurnEnd as before.

(2) Retire / Kill — was branched: PvP pushed Lose=0 (NotFinish) /
Win=1 (NoContest); Scripted pushed BuildBattleFinishNoContest() (Win=1).
Both shipped wrong RESULT_CODE values; the audit doc's outstanding item
documented this. Unified:
  - push BattleFinish(RetireLose=106) to from (the retirer)
  - push BattleFinish(RetireWin=105) to other (the survivor)
  - Phase → Terminal

  Added RetireWin=105 / RetireLose=106 to BattleResult enum with the
  same player-perspective convention.

(3) PvP gameplay forwarder (TurnStart / PlayActions / Echo /
TurnEndActions / JudgeResult) — had a redundant `Type == BattleType.Pvp`
guard. Verified that BothAfterReady() is naturally only true when both
participants are RealParticipant (ScriptedBot / NoOpBot don't implement
IHasHandshakePhase per RealParticipant.cs:20-23 / Participants/*.cs grep).
Dropped the redundant guard.

Bot type still has its dedicated InitBattle/Loaded/TurnEnd arms above
the unified ones, so Bot-specific behavior is unchanged.

Tests: 177 battle-node tests passing.
- Updated 9 tests to match the unified dispatch (paired BattleFinish
  pushes, correct RESULT_CODE values, forwarded TurnEndFinal envelope).
- ScriptedBotParticipantTests.PushAsync_TurnEndFinal_* rewritten to
  assert the bot does NOT fire on TurnEndFinal (was asserting it did).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:37:24 -04:00
gamer147
1685b509c3 fix(battle-node): TurnEndFinal pushes LifeWin=101 (player perspective), not LifeLose=102
End-to-end trace of FinishBattleEffect proved my prior direction was
backwards. The path is:

  RESULT_CODE → JudgeResultReceive switch (NetworkBattleManagerBase:1439-1459)
              → SettingResultUI_SpecialResultTypeText
              → _finishEffectType = battleResult
  → eventually FinishBattleEffect(:1267-1316):
      bool isPlayer = false;
      switch (_finishEffectType) {
        case WIN:  isPlayer = true;  break;
        case LOSE: isPlayer = false; break;
      }
      InitiateGameEndSequence(!isPlayer);  // NEGATED
  → BattleManagerBase.InitiateGameEndSequence(hasWon):
      hasWon=true → WIN screen; hasWon=false → LOSE screen.

So LifeWin=101 (player perspective: "I won by life") → _finishEffectType=LOSE
→ isPlayer=false → hasWon=true → WIN UI. And LifeLose=102 ("I lost") → LOSE UI.

My prior misread treated the inner switch's BATTLE_RESULT_TYPE param as
the final UI render — but that param only feeds the secondary "by retire
/ by disconnect" text, not the primary WIN/LOSE. The real flip happens at
FinishBattleEffect:1315's !isPlayer negation.

User's live repro (bot HP to 0 → LOSS screen) confirmed the inversion.

The prior prod TK2 capture interpretation was also corrected: line 274
`result:102` was a LOSS capture (player lost to the opponent's attack on
line 271), not a win as I claimed earlier.

Changes:
- BattleResult.cs: docstring rewritten with the full FinishBattleEffect
  trace. Members reordered (LifeWin first since it's used by Scripted).
- BattleSession.cs:267: Scripted TurnEndFinal arm pushes LifeWin instead
  of LifeLose.
- Test updated to assert LifeWin=101 + describe the inversion lesson so
  the next reader sees the prior bug context.

177 battle-node tests passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:11:24 -04:00
gamer147
ee23985055 fix(battle-node): push BattleFinish on Scripted TurnEndFinal so the client doesn't park on the disconnect-checker
Before: when the player declared their final winning turn (TurnEndFinal),
Scripted mode forwarded it to the bot — which fired a useless 3-frame
TurnStart/TurnEnd/Judge burst as if the game were continuing. No
BattleFinish was ever pushed, so the client's
BattleFinishToOpponentDisConnectChecker (NetworkBattleManagerBase.cs:1640
+ BattleFinishToOpponentDisConnectChecker.cs) parked the player on a
"waiting for opponent" dialog for 128 seconds, eventually falling through
to a synthetic OnDisConnectWin. The user could see "opponent defeated"
animations but couldn't proceed to the post-battle screen.

After: Scripted TurnEndFinal pushes BattleFinish with result=LifeLose=102
to the player (matches the RESULT_CODE the client expects per
NetworkBattleReceiver.cs:963-986; client maps LifeLose → "opponent's life
ran out, PLAYER WIN" UI per NetworkBattleManagerBase.cs:1450-1459). Phase
transitions to Terminal so RunAsync's PvP-disconnect cascade doesn't
synthesize a second BattleFinish on top. No bot burst — the game is
over.

Wire reference: prod TK2 capture battle-traffic_tk2_regular.ndjson:273-274
shows server pushing TurnEndFinal followed immediately by BattleFinish
result:102.

BattleResult enum gets the LifeWin=101 / LifeLose=102 values and a
corrected docstring. The pre-existing Lose=0 / Win=1 / Consistency=2
values stay (Retire/Kill flow ships them today and works as "no contest"
end-of-battle), but their docstring no longer claims they're the WS
shape — they were always the HTTP /finish shape, mislabeled.

TurnEnd (regular, not final) keeps the existing forward-to-bot behavior
in Scripted mode — that's a normal turn boundary, not game end.

PvP TurnEndFinal still broadcasts the same TurnEnd+Judge as regular
TurnEnd; the actual game-end BattleFinish push in PvP rides the loser's
Retire/Kill or the disconnect cascade in RunAsync.

177 battle-node tests passing (was 176; +1 covering the new dispatch arm).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:48:20 -04:00
gamer147
c7e61c6f8d fix(battle-node): hand events are unencrypted JSON arrays, not encrypted dicts
The prior 'hand'-ack fix worked in test but failed in prod because both
the handler and the test used the wrong wire shape. Re-tracing the
client emit path:

  RealTimeNetworkAgent.cs:783-786 (msg path):
    return MessagePackSerializer.Serialize(
        CryptAES.encryptForNode(JsonMapper.ToJson(info)));  // ← encrypted

  RealTimeNetworkAgent.cs:815-817 (hand path):
    return MessagePackSerializer.Serialize(
        JsonMapper.ToJson(info));                           // ← NOT encrypted

And EmitFrontStockData:717-723 picks "hand" as the SIO event name only
when frontData["StockHandData"] exists; in that branch it passes the
StockHandData list (NOT the dict) to CreatePackEmitHandData. So the
wire body is:

  msgpack_string(JsonMapper.ToJson(List<object>))

i.e. a JSON array, unencrypted. EmitMsgUriPack:1456-1458 puts pubSeq at
index 3 of that array (after uri_int / viewerId / udid). The dict's
top-level pubSeq stays client-local for stockEmitMessageMgr.GetSelectData.

Handler now:
- Skips NodeCrypto.DecryptForNode (was throwing FormatException on the
  unencrypted bytes — caught and swallowed silently by the existing
  outer try/catch, so the bug presented as 'no warning, no ack')
- Parses RootElement.ValueKind:
  - Array → arr[3] is the pubSeq
  - Object → top-level "pubSeq" (defensive; not used by prod today)
- Falls back to ack arg=0 if neither extraction works (the client's
  GetSelectData lookup misses but its OnAck path still fires — same as a
  normal cache-miss — so the queue still drains)

Diagnostic [hand-rx] log added (gated by DiagnosticLogging) so we can
see the actual body content per-frame during verification.

Test was also wrong (encrypted dict shape); rewritten to use the real
wire shape (unencrypted JSON array). +1 net new test covering the
dict-shape defensive path.

176 battle-node tests passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:14:13 -04:00
gamer147
8f270a87f0 refactor(battle-node): gate WS diagnostic logging behind config flag
The temporary [sio-in] / [sio-out] / [ws-rx-text] / [ws-rx-bin] /
[ws-recv-exit] / [ws-loop-exit] logs added during the hand-ack
investigation are useful enough to keep around (PvP testing, future WS
debugging) but too chatty to leave on by default. Promote them from
"strip before merge" to a permanent opt-in.

New BattleNodeOptions.DiagnosticLogging (bool, default false). Wired
through BattleNodeWebSocketHandler to RealParticipant via a new optional
ctor parameter (default false — existing test sites pick up the silent
default with no changes). Every Information/Warning log added during the
investigation is now if-gated; non-diagnostic logs (the decode-failure
warnings, the dispatch-drop debug) stay as-is.

Toggle via appsettings*.json:
  "BattleNode": { "DiagnosticLogging": true }

Or live via the singleton:
  factory.Services.GetRequiredService<BattleNodeOptions>().DiagnosticLogging = true

175 battle-node tests still passing — existing tests use the constructor
default and emit nothing, so no test changes were required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:49:17 -04:00
gamer147
9fc1d055d8 fix(battle-node): ack 'hand' SIO events to unblock client emit queue
Scripted-bot softlock root cause: client-stocked SELECT_SKILL_URI /
SLIDE_OBJECT_URI hand emits (e.g. target selection on unit play / leader
attack) arrive as SIO BinaryEvent("hand", ...) with an ack-id. Our
DispatchSocketIo only had cases for "msg" and "alive" — "hand" fell to
the default Debug-drop with no SIO ack going back. Client's
stockEmitMessageMgr (RealTimeNetworkAgent.cs:1463) blocks subsequent
emits until the previous one is acked, so all follow-up PlayActions /
TurnEndActions / TurnEnd frames were stocked but never transmitted. The
loader hooks at EmitMsg (intent) not the socket layer, which is why
battle-traffic.ndjson shows the frames as sent while the server never
received them. ~10s later the client gives up and aborts the WS.

Wire-shape proof from data_dumps/captures/logs/websocket_output.txt:
  line 619: [sio-in] uri=TurnStart pubSeq=17 ackId=16 ... (T3 start)
  line 689: [ws-rx-text] preview=451-26["hand", {...}] ← unhandled
  line 691: [ws-rx-bin]  binLen=58 pendingFrame=hand
  (no further [sio-in] entries — server received nothing else)
  line 709: [ws-recv-exit] reason=OperationCanceled wsState=Aborted

New HandleHandEventAsync (RealParticipant.cs):
- Fire-and-forget hand frames (no ack-id; TOUCH_URI / SELECT_OBJECT_URI /
  TURN_END_READY_URI) are silently swallowed — no queue-blocking risk
- Stocked hand frames decode the binary attachment via the same
  msgpack-string + NodeCrypto.Decrypt pipeline as HandleMsgEventAsync,
  parse the JSON, extract top-level "pubSeq", and SendSioAckAsync with
  that pubSeq as the ack arg (matches what stockEmitMessageMgr.GetSelectData
  expects to look up)
- Body shape is {"StockHandData":[uri_int, viewerId, udid, ...params,
  pubSeq], "try":0, "pubSeq":N} — NOT a MsgEnvelope (no top-level "uri"),
  so we can't reuse HandleMsgEventAsync as-is
- Missing-pubSeq fallback acks with arg=0 (rare path, logged at Warning)
  so we never softlock from a malformed body

WireConstants gets the HandEvent = "hand" constant for the dispatch case.

In scripted/Bot mode the ack-only handler is correct (no opponent to
forward touches to). PvP-side forwarding semantics are unverified — see
docs/audits/battle-node-sio-events-2026-06-02.md (outer repo) for the
full event inventory and remaining gaps.

Tests:
- RealParticipantHandEventTests covers the three paths: stocked-with-ack,
  fire-and-forget (no ack expected), missing-pubSeq fallback (arg=0). Each
  drives a real hand frame through RunAsync via TestWebSocket and asserts
  the SIO ack frame shape (43<ackId>[<arg>]) in outbound sends.
- 175 battle-node tests passing (was 172; +3 new). Full suite green.

Diagnostic logs ([sio-in] / [sio-out] / [ws-rx-text] / [ws-rx-bin] /
[ws-recv-exit] / [ws-loop-exit]) are left in place for one verification
cycle. After a live re-run confirms the fix, they should be stripped per
the audit doc's recommended-order step 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:41:40 -04:00
gamer147
672a89ed46 refactor(matching): IMatchingResolver shared by every do_matching family
SoloDefaultsToScripted was only consulted by ArenaTwoPickBattleController;
RankBattleController did its own inline pair-up + state-code mapping and
ignored the flag entirely. Result: turning on the flag globally only
short-circuited TK2 polls, while rank-battle polls still parked for the
PvpFirstThenAiFallback threshold (15s) before resolving — surfaced today
when the user set the flag and saw rank-battle still queue, then bot-
battle via the client-side AI (not the server-side Scripted lifecycle we
need to test WS traffic against).

New IMatchingResolver owns the cross-cutting decisions:
- honor scriptedOptIn (per-request) OR options.SoloDefaultsToScripted
  (process-wide) — bypass pair-up, register Scripted, return 3004
- otherwise call IMatchingPairUpService.TryPairAsync and translate the
  PairUpResult to the 3002/3004/3007/3011 vocabulary

Family controllers shed the duplicated logic:
- ArenaTwoPickBattleController: ~50 LOC → ~25; preserves ?scripted=1
  query opt-in (parsed permissively for "1"/"true") and the
  ArenaTwoPickException catch
- RankBattleController: ~30 LOC → ~12; preserves the 3001 mapping for
  InvalidOperationException (no deck for format) and card_master_id
  emission

DoMatchingContractTests is the durable enforcement: parametrized over
TK2 + rotation + unlimited rank, asserts SoloDefaultsToScripted=true
makes every family's first poll skip 3002 and return SUCCEEDED with a
battle_id + node_server_url. Adding a fourth family that forgets to
route through IMatchingResolver fails this test — that's the point.

MatchingResolverTests covers the six resolver paths in isolation with
mocks; per-test Harness locals (not fixture-level fields) because the
assembly is [Parallelizable(ParallelScope.All)] and shared mocks race.

957 tests passing (was 948; +9: 6 resolver + 3 contract parametrizations).
No regressions in the existing TK2 / rank-battle controller suites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:18:48 -04:00
gamer147
9f11896f7b feat(battle-node): polite Socket.IO close on waiting-room timeout
The PvP waiting-room timeout path in BattleNodeWebSocketHandler used to
return immediately after RemovePending, leaving the parked first arriver
to learn about the disconnect via TCP teardown after Kestrel finished
draining the request. BestHTTP / socket.io-client log that as an abrupt
drop rather than a controlled disconnect.

New TryPoliteCloseAsync helper emits an EIO "1" (Close) text frame, then
runs the WebSocket close handshake with NormalClosure. Wrapped in
try/catch + Debug log — teardown races between the server-side close and
client disconnect are routine and not actionable. Uses a fresh 5s CTS so
ctx.RequestAborted being canceled doesn't skip the close.

Wired into both bail-out paths post-AcceptWebSocketAsync that previously
just returned:
- PvP waiting-room timeout / Park-Park race (the main case, per PLAN.md
  L104 (c))
- Unknown BattleType default case (same shape, log message already said
  "closing WS" but didn't actually close — opportunistic fix)

PvpWaitingRoomTimeout integration test tightened: now asserts the polite
"1" text frame arrives before the close handshake, not just that the WS
eventually closes by any means.

172 battle-node tests passing (was 172 before the assertion tightening;
the existing timeout test stayed in.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 14:05:25 -04:00
gamer147
a6b9a942ab chore(battle-node): delete dead ScriptedProfiles.Opponent{Matched,BattleStart}Profile fields
Phase 2 absorbed the scripted opponent cosmetics + class/chara fixture
into ScriptedBotParticipant.Context; the two profile fields have been
unreferenced since (kept one phase as documentation tie-back, per PLAN.md
L104 (d)). The Context comments now describe the values directly with
frame[N] provenance instead of pointing at the deleted fields. Also
removes the now-unused SVSim.BattleNode.Protocol.Bodies import from
ScriptedProfiles.cs.

948 tests passing (unchanged).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:58:32 -04:00
gamer147
5c4e427fab feat(battle-node): clear RealParticipant outbound archive on session terminate
Closes audit Md11. BattleSession.RunAsync now clears each
RealParticipant.Outbound archive immediately before the TerminateAsync
cascade, releasing the heavy dict the moment the battle ends instead of
waiting for the participant to be GC'd. Bots (NoOp / Scripted) don't
expose an OutboundSequencer, so the 'p is RealParticipant rp' conditional
cast is the natural filter.

Tests: 1 new BattleSessionTerminateCascadeTests — pre-load the archive,
drive RunAsync to completion via TestWebSocket.CompleteIncoming, assert
the archive is empty. Suite: 939 → 948.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:10:15 -04:00
gamer147
10d9f74d05 feat(battle-node): add OutboundSequencer.Clear() for terminate cascade
Audit Md11 (part 2 of 2). Adds an explicit Clear() so BattleSession can
release the archive at battle-end instead of waiting for the participant to
be GC'd. _next is intentionally NOT reset — a post-Clear emit is a bug per
the design, but the seq stream must stay monotonic if it does happen.

Tests cover empty archive after Clear, _next preservation across Clear,
and Clear-on-empty no-op. The BattleSession integration that calls Clear
lands in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:07:00 -04:00
gamer147
3991bcc653 feat(battle-node): bound InboundTracker with watermark-guarded sliding window
Audit Md11 (part 1 of 2). Replace unbounded HashSet<long> _seen with a
WindowSize=256 ring (HashSet + Queue, LRU eviction). The stale-below-window
guard (pubSeq <= HighWaterMark - WindowSize) prevents window eviction from
re-admitting old seqs as novel — the load-bearing invariant.

pubSeq is client-monotonic and SIO retransmit horizons are seconds-scale, so
256 covers realistic retries by a wide margin. HighWaterMark semantics
preserved (Gungnir still reports it).

Tests: 5 new InboundTrackerTests covering below-window guard, evicted-seq
rejection, within-window dedup after eviction, memory bound, and watermark
monotonicity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:06:08 -04:00
gamer147
898b872edd fix(rank-battle): route ai-start through the queue-time MatchContext
Live-smoke bug 2026-06-02: queued Bloodcraft (deck #5), wire showed
classId=2 (Swordcraft) for self_info on the /ai_unlimited_rank_battle/start
response — client rendered the wrong leader.

Two layers of the same bug:

1. MatchContextBuilder.BuildForRankBattleAsync hardcoded deckNo=1 instead
   of taking it from the do_matching request — verified against
   data_dumps/captures/traffic.ndjson L17 where deck_no=5 was on the wire.
   Signature changes to (viewerId, format, deckNo); DoMatchingInternal
   passes req.DeckNo.

2. AiStartInternal rebuilt MatchContext from scratch — but the /ai_*/start
   request body is BaseRequest only, no deck_no on the wire. The fix uses
   the MatchContext the bridge already stored at do_matching resolution time
   (in the Bot PendingBattle), so deck/cosmetic data is consistent end-to-end.
   New IBattleSessionStore.TryFindPendingForViewer(viewerId) finds the
   viewer's pending battle for lookup. The store entry persists across
   ai_start (idempotent reads are fine — the WS handler removes on connect).
   No-pending sentinel: ai_id=-1 surfaces the "no AI assigned" error in the
   client.

Tests: 936 → 939 passing.
- MatchContextBuilderTests.BuildForRankBattle_uses_the_caller_supplied_deck_number
  seeds deck #1 (class 1) and deck #5 (class 6) and asserts the deckNo
  argument picks the right one.
- RankBattleControllerTests.AiStart_self_info_class_matches_queued_deck_number
  is the end-to-end regression: register Bot battle with deck #5, hit
  /ai_unlimited_rank_battle/start, assert self_info.classId == 6.
- RankBattleControllerTests.AiStart_without_pending_battle_returns_neg1_sentinel
  locks the defensive ai_id=-1 path.
- Existing AiStart_* tests bypass do_matching, so adapted to call a new
  RegisterBotBattleAsync helper that mirrors what InProcessPairUp does on
  AI-fallback resolution.

SeedDeckAsync gains an optional classId so test cases can differentiate
decks by class (was always picking Classes.First()).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 12:28:42 -04:00
gamer147
24f9b2240e feat(matching): move BotRoster from hardcoded fixture to DB-backed seed
Phase 3 shipped the AI rank battle bot pool as a hardcoded 8-entry list
inlined in SVSim.EmulatedEntrypoint/Matching/BotRoster.cs — editing meant
recompiling. Per PLAN.md 2026-06-02 item (d), move it to a Bootstrap
importer so the roster lives in seeds/bot-roster.json and the DB.

Shape mirrors PracticeOpponent end-to-end:
- BotRosterEntry (SVSim.Database/Models) — PK = AiId via the Id passthrough
  pattern. DbSet<BotRosterEntry> BotRoster on SVSimDbContext.
- AddBotRoster migration (DDL only, per migrations-are-DDL-only rule).
- seeds/bot-roster.json — 8 rows preserving the current prod-verified
  cosmetic ids (sleeve 704141010 / emblem 400001100 / degree 120027 /
  field 5) and series-1 ai_ids from rm_ai_setting.csv (1111..1181).
- BotRosterSeed POCO + BotRosterImporter (idempotent upsert keyed by AiId,
  leaves seed-missing rows intact). Wired into SVSim.Bootstrap/Program.cs
  next to PracticeOpponentImporter.
- IGlobalsRepository.GetBotRoster() + impl.

IBotRoster.Pick → PickAsync because BotRoster now depends on the transient
IGlobalsRepository. RankBattleController awaits the new signature. The
deterministic hash-on-ctx invariant (same ctx → same bot, so /ai_<fmt>/start
retries pick the same opponent) is preserved.

DI: AddSingleton<IBotRoster> → AddTransient (matches IGlobalsRepository's
lifetime). Test fixture's SeedGlobalsAsync also runs the importer so
RankBattleControllerTests + the rewritten BotRosterTests both see seeded
rows.

Tests: 931 → 936 passing. Existing 3 BotRosterTests reshaped for the DB
backing + 1 new "throws on empty roster" guard; 4 new
BotRosterImporterTests mirror PracticeOpponentImporterTests
(round-trip / idempotent / seed-missing-row-intact / ai_id=0 skip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:58:19 -04:00
gamer147
8aead62116 fix(battle-node): revert Bot Matched/BattleStart push (corrupts OppoBattleStartInfo)
Previous commit 51e9dd2 changed Bot's InitBattle to push Matched and
Loaded to push BattleStart+Deal, on the theory that the architecture
spec's "no Matched in Bot mode" claim was wrong. That theory was based
on misreading Matching.cs:400 (the Matched handler) as a required
state-machine trigger.

End-to-end trace of the AI client flow shows:

  1. _initNetworkSuccess (set when the client receives uri=InitNetwork,
     i.e., our ack) is the actual trigger — MatchingNetworkConnectChecker
     phase 3 sees it and calls MatchingInitBattle.

  2. MatchingInitBattle (Matching.cs:298) for IsAINetwork IMMEDIATELY
     calls StartBattleLoad + GotoBattle right after emitting InitBattle.
     It does NOT wait for any wire envelope.

  3. The Matched handler at Matching.cs:400 is gated on
     status == Connect and is already past Prepared by the time the
     wire round-trip completes — sending Matched is harmless but
     unnecessary.

  4. The BattleStart handler at Matching.cs:417 runs UNCONDITIONALLY and
     SetNetworkInfo at RealTimeNetworkAgent.cs:1562 overwrites
     OppoBattleStartInfo with the wire envelope's oppoInfo. Our oppoInfo
     comes from NoOpBotParticipant.Context placeholders (classId/emblemId
     etc. = 0), corrupting the good values the client set from the HTTP
     AIBattleStart response.

The "Waiting for opponent" hang was caused by SBattleLoad.LoadOpponentAssets
trying to fetch emblemId=0, degreeId=0, etc. after BattleStart corrupted
OppoBattleStartInfo. The asset group load silently hangs on missing
assets, no error logged.

Restored the spec's original Bot arms: InitBattle ack-only, Loaded silent,
TurnEnd Judge-to-sender. ai-passive.md updated with the corrected reasoning
and a discovery-history note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:23:13 -04:00
gamer147
d87f9beb81 fix(rank-battle): use prod-verified bot cosmetic ids to unblock LoadOpponentAssets
The "Waiting for opponent" hang traced to BattleStartControl.IsReady never
flipping true. That's gated by SBattleLoad.LoadOpponentAssets which calls
ResourcesManager.LoadAssetGroupSync with the bot's
{rank, emblemId, degreeId, countryCode} — and our placeholder ids (1/1/1/"NONE")
don't resolve to any asset in the client's resource bundle, so the callback
never fires.

Replaced with the Scripted bot's known-good prod values:
- SleeveId: 704141010
- EmblemId: 400001100
- DegreeId: 120027
- FieldId: 5
- CountryCode: "JPN"
- IsOfficial: 0

These are the same ids ScriptedBotParticipant.Context uses, which we know
load fine because the TK2 Scripted flow has been working end-to-end since
Phase 2.

Reference for the load chain (decompiled client):
  BattleUI.WaitForSetUp → m_SBattleLoad.WaitCallBack
    → BattleStartControl.SetUp → CheckAbleToInitialize
    → SBattleLoad.LoadOpponentAssets (SBattleLoad.cs:933)
    → ResourcesManager.LoadAssetGroupSync — hangs on missing assets

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:09:20 -04:00
gamer147
51e9dd2094 fix(battle-node): Bot mode must push Matched + BattleStart (client state-machine triggers)
Phase 3 shipped a Bot dispatch table that ack'd InitBattle without
pushing Matched and stayed silent on Loaded, per the architecture spec's
inference that "the client uses AIBattleStart HTTP data instead of
Matched in Bot mode." That inference was wrong.

The client's matching state machine (Matching.ReactionReceiveUri,
Matching.cs:400) gates StartBattleLoad() on the Matched envelope, and
BattleStart at Matching.cs:417 triggers GotoBattle. Without those
envelopes the client never transitions out of MatchingStatus.Connect —
which renders as the "Waiting for opponent" hang on the loading screen.
AIBattleStart HTTP only provides opponent cosmetics, not state-machine
triggers.

Fix: drop the Bot-specific InitBattle ack-only and Loaded silent arms;
let Bot fall through to the existing handshake arms that push Matched
and BattleStart + Deal. Only TurnEnd stays Bot-specific (Judge to
sender, not broadcast — there's no real other side to broadcast to).

Tests updated to match the corrected contract. ai-passive.md doc
amended with a correction note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:56:22 -04:00
gamer147
45c4461515 fix(rank-battle): use real rm_ai_setting.csv ai_id values in BotRoster
Phase 3 shipped placeholder ai_id values 4001..4008, which the client's
RankMatchAISettingList.GetSettingData() couldn't resolve — the lookup
is .First() against the rm_ai_setting.csv master table and throws
InvalidOperationException ("Sequence contains no matching element")
when the id isn't present. Surfaced on live smoke as a Unity error
during battle load:

  Wizard.RankMatchAISettingDataSet.GetSettingData (System.Int32 enemyAiId)
  BattleUI+<WaitForSetUp>d__9.MoveNext ()

Replaced with the series-1 enemy_ai_id per class from
data_dumps/client-assets/rm_ai_setting.csv:
  1111=Forest, 1121=Sword, 1131=Rune, 1141=Dragon,
  1151=Shadow, 1161=Blood, 1171=Haven, 1181=Portal

Practice mode's AI catalog (practice_ai_setting.csv) uses a different
schema keyed by (class_id, difficulty) with no enemy_ai_id field, so
practice ids aren't reusable here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:41:38 -04:00
gamer147
bf783639c1 fix(rank-battle): inherit BaseRequest so auth fields survive translation roundtrip
The translation middleware decrypts + msgpack-decodes the request body
into the action's first-parameter type, then re-serializes that DTO to
JSON for the auth handler to read. Phase 3's DoMatchingRequestDto and
RankBattleFinishRequestDto didn't inherit BaseRequest, so viewer_id /
steam_id / steam_session_ticket were dropped during the msgpack → DTO
→ JSON pivot — the auth handler then saw a body with no auth fields
and 401'd every request.

Fixed by making both DTOs extend BaseRequest, mirroring the Phase 2 TK2
DoMatchingRequest pattern.

Also added [FromBody] BaseRequest parameters to the previously body-less
actions (AiStart × 2, ForceFinish, AddClientLog, GetLatestMasterPoint).
The translation middleware explicitly requires at least one parameter
to bind the decrypted msgpack body (see L130-136 of the middleware);
without it the request would throw InvalidOperationException at runtime.

Tests updated to post viewer_id / steam_id / steam_session_ticket
placeholder values in the request body, matching the existing TK2 test
pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:29:48 -04:00
gamer147
8723cff998 test(battle-node): BotBattle_FullLifecycle integration test
Single-client end-to-end Bot lifecycle: InitNetwork → ack, InitBattle →
ack (no Matched), Loaded → silent, Swap → SwapResponse + Ready,
two TurnEnd cycles each producing a single Judge frame back to sender,
Retire → BattleFinish. Pending battle evicted at session start.

Closes Phase 3 — battle-node v2's three-phase migration (Scripted → PvP →
Bot) is now complete. Test budget: 884 → 931 (+47 across Phase 3).
Next: matching-queue API rewrite + real rank progression, as separate
specs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:32:07 -04:00
gamer147
fee84cca24 feat(battle-node): wire WS handler's case BattleType.Bot to real (Real, NoOp) session
Replaces the Phase-2 log-and-return stub with a real session
construction. P2 is always null for Bot (bridge contract), so no
WaitingRoom flow needed — single real WS, Phase-1 WhenAll-everything
RunAsync semantics work because NoOp.RunAsync completes immediately.
Integration test follows in the next task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:29:11 -04:00
gamer147
a4685a9188 feat(battle-node): Bot dispatch arms in ComputeFrames
Three new arms gated on Type == BattleType.Bot, placed before the
existing PvP / Scripted arms:
- InitBattle → ack to sender (no Matched push — client uses AIBattleStart HTTP data)
- Loaded → silent (no BattleStart, no Deal — client short-circuits to GotoBattle)
- TurnEnd / TurnEndFinal → Judge to sender only (not broadcast)

Other URIs in Bot mode fall through existing arms: Swap is Type-agnostic
(per-sender SwapResponse + Ready), Retire/Kill hits the existing
Scripted no-contest BattleFinish(Win), gameplay forwarders are gated on
Pvp so Bot's PlayActions/Echo/etc. fall through default (drop). 8 new
dispatch tests cover the wire contract.

Reference: docs/api-spec/in-battle/ai-passive.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:27:08 -04:00
gamer147
07eb6f1c05 feat(rank-battle): AiStart returns ai_id + camelCase self/oppo_info
AiStartInternal builds the self MatchContext, picks a bot from
IBotRoster, projects to the AiBattleStartResponseDto with camelCase
wire keys (sleeveId, emblemId, ... — see ai-start.md). turnState=0
(player first) is the safe default per the ai-start.md TODO; live
capture would clarify the enum.

No deck → ai_id=-1 fallback (the documented "no AI assigned" sentinel
per AIBattleStartTask.cs:21). 3 new wire-shape tests assert the
camelCase keys land verbatim in the JSON, plus self/oppo info come from
the right sources.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:24:04 -04:00
gamer147
bb63b0df2f feat(rank-battle): real DoMatching with PvP pair + AI fallback mapping
DoMatchingInternal calls IMatchingPairUpService.TryPairAsync, then maps:
- null result → 3002 RETRY (empty node_server_url, no battle_id)
- IsAiFallback → 3011 AI_BATTLE_MATCHING_SUCCEEDED
- IsOwner → 3007 SUCCEEDED_OWNER (cache pickup)
- joiner → 3004 SUCCEEDED

BuildForRankBattleAsync's InvalidOperationException (typically "no deck
for format") surfaces as 3001 ILLEGAL so the client shows the
matchmaking-error dialog rather than retrying.

card_master_id is a placeholder (0) per the per-battle card-master
split deferral. AI-fallback timing is covered by InProcessPairUp unit
tests; controller tests focus on the wire mapping (3002, 3004, 3007).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:21:38 -04:00
gamer147
7c4aa89d45 feat(rank-battle): RankBattleController shell + DTOs + routing smoke tests
Stands up the controller with all 13 rank-battle URL routes wired via
explicit absolute [HttpPost] attributes (multi-prefix family — can't ride
[Route(\"[controller]\")]). Real DoMatching / AiStart logic arrives in
later tasks; finish + telemetry + force-finish are returnable stubs as
of this task.

DTOs cover the request + response shapes per the spec. Note the
camelCase wire keys on AiBattlePlayerInfo (sleeveId, emblemId, ...) —
the AI battle subsystem uses camelCase, not the project-default
snake_case, per AIBattleStartTask.Parse's literal Keys.Contains lookups.

DoMatchingResponseDto.NodeServerUrl is non-nullable + always-emit (with
[JsonIgnore(Never)]) — matches Phase 2's TK2 fix because the client's
DoMatchingBase parser calls .ToString() without a Keys.Contains guard.

13 routing smoke tests confirm each URL resolves to the controller.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:19:02 -04:00
gamer147
a55187e10e feat(matching): IBotRoster + hardcoded BotRoster fixture (8 bots, one per class)
AIBotProfile carries the cosmetic metadata the AI rank-battle start
endpoint composes into oppo_info. BotRoster.Pick is deterministic per
MatchContext so mid-flight retries get the same opponent. ai_id values
4001..4008 are placeholders per the existing ai-start.md TODO — we have
no live capture of the prod catalog.

Future improvement: migrate Roster to a bot-roster.json seed under
SVSim.Bootstrap/Data/seeds/ for editability without rebuilds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:15:41 -04:00
gamer147
7eaf13893e feat(matching): MatchContextBuilder.BuildForRankBattleAsync for rank battles
Sibling to BuildForTwoPickAsync. Routes through IDeckRepository.GetDeck
to pull the viewer's deck #1 for the requested format (avoiding the
viewer-graph nav-ref auto-load pitfall — DeckCard.Card silently ships
card_id=0 via the default include path). Throws if the viewer has no
deck for the format. Cosmetics fall back to DefaultLoadoutConfig
defaults when unequipped, same shape as TK2.

Used by RankBattleController in a later task to build self-context for
/ai_<fmt>_rank_battle/start and to pair-up under /<fmt>_rank_battle/do_matching.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:13:19 -04:00
gamer147
b65cf81977 feat(matching): per-mode policy + AI-fallback branch in InProcessPairUp
InProcessPairUp now consults ModePolicyRegistry per call and reads the
fallback threshold from MatchingConfig via IServiceScopeFactory (singleton
service consuming a scoped IGameConfigService). New behavior for
PvpFirstThenAiFallback modes: when the calling viewer IS the slot's
waiter and Now - WaitingSince >= threshold, the waiter unparks and the
bridge resolves a Bot match. PvpOnly modes (TK2) keep parking forever
(modulo a 5-minute stale-waiter eviction backstop).

TimeProvider is injected so tests can drive time forward with
FakeTimeProvider — 7 new tests cover the four key transitions
(stay-parked / pair-pvp / fall-back / stale-evict) plus per-mode
isolation. Fixture uses [FixtureLifeCycle(InstancePerTestCase)] because
the assembly is Parallelizable(ParallelScope.All).

Program.cs registers ModePolicyRegistry with three rows: TK2 PvpOnly,
rotation/unlimited rank PvpFirstThenAiFallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:09:42 -04:00
gamer147
3866c93065 refactor(matching): extend PairUpResult with IsAiFallback flag
Pure shape change ahead of Phase 3 AI-fallback wiring — all current
callers pass IsAiFallback: false. TK2 will always emit false (PvpOnly
policy); rank-battle's PvpFirstThenAiFallback branch sets true after
the threshold elapses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:57:25 -04:00
gamer147
d7bb44973a feat(matching): ModePolicy registry for per-mode pair-up policy
Adds PolicyKind enum (PvpOnly, PvpFirstThenAiFallback), ModePolicy
record, and ModePolicyRegistry singleton with last-wins dict + PvpOnly
default for unknown modes. Wired into InProcessPairUp in a later task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:55:18 -04:00
gamer147
b17c802581 feat(config): MatchingConfig section with AI-fallback threshold
Adds a [ConfigSection("Matching")] POCO carrying the
RankBattleAiFallbackThresholdSeconds tunable (default 15). Auto-picked
up by EnsureSeedDataAsync's reflection-based ConfigSection seeder.
Consumed by InProcessPairUp in a later task.

GameConfigurationJsonbTests.EnsureSeedData_writes_one_row_per_ConfigSection_with_ShippedDefaults_payload
updated to include the new Matching section in its expected keys.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:54:44 -04:00
gamer147
0095bdf0cf feat(arena-tk2): SoloDefaultsToScripted config flag for dev convenience
Adds BattleNodeOptions.SoloDefaultsToScripted (default false). When true,
the TK2 do_matching controller treats every solo poll as if ?scripted=1
were passed and returns a Scripted 3004 match immediately — useful for
the live client (which can't append query params) to drive the scripted
bot without needing a second player.

Toggle via "BattleNode:SoloDefaultsToScripted" in appsettings*.json
(Program.cs now binds the BattleNode section over the AddBattleNode
defaults). Turn off to test real PvP with two clients.

Trade-off documented on the option: while on, two simultaneous pollers
each get their own Scripted match instead of pairing, so PvP is
effectively disabled until the flag is flipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:48:14 -04:00
gamer147
8112b3f81f feat(arena-tk2): split do_matching success into 3007 owner / 3004 joiner
Mirrors prod's TK2 wire flow: the first arriver (parked, picks up cached
pair on a later poll) gets matching_state 3007 (SUCCEEDED_OWNER); the
second arriver (whose poll triggered the pair) gets 3004 (SUCCEEDED).

Observationally inert in the public matching code path today — the
client's Matching class writes isOwner from the response into a field
that nothing in TK2/ranked reads. Matching_Room (private rooms) DOES
read it but from a separate code path that doesn't consult our response.
We send the split anyway for prod fidelity and to leave room for future
flows (rematch UI, etc.) that might start consuming it.

TryPairAsync now returns PairUpResult(Match, IsOwner) instead of bare
PendingMatch?, so the controller can decide owner vs joiner without
re-deriving it.

Also documents on DoMatchingResponseDto why we omit prod's `room_id`
field (not in the client's DoMatchingDetail model; private-room flows
get their room id from a different API and don't consult this response).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:24:13 -04:00
gamer147
0ecd565774 fix(arena-tk2): park returns 3002 RETRY + empty node_server_url
Two client-crash bugs in the do_matching response when no partner is
waiting:

1. matching_state was 3001 (RC_BATTLE_MATCHING_ILLEGAL); the client's
   Matching.OnFinishedDoMatching switch maps that to an error dialog,
   not a retry. The retry state is 3002 (RC_BATTLE_MATCHING_RETRY).

2. node_server_url was omitted entirely. The client's
   DoMatchingBase.SettingDoMatchingData reads it via
   data["node_server_url"].ToString() with no Keys.Contains guard, so
   absence throws KeyNotFoundException out of NetworkManager.Connect
   before the matching_state switch is even reached. Prod RETRY
   captures send "" while waiting and the real URL only on SUCCEEDED;
   match that.

battle_id stays absent; its accessor IS guarded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 22:50:48 -04:00
gamer147
43c0a6cf31 test(battle-node): PvP integration tests (handshake, gameplay, Retire, disconnect, timeout)
Four end-to-end tests against two parallel RawSocketIoTestClients:
handshake to AfterReady on both sides with per-perspective Matched;
TurnEnd broadcast to both sides + Judge; A's PlayActions forwarded to
B; Retire flipped to Lose-for-sender, Win-for-other; A's abrupt WS
close cascades to BattleFinish(Win) for B with PendingBattle eviction;
waiting-room timeout closes the first arriver's WS (fallback long-wait
path — the 60s default is left in place; TestServer-side WS close is
observed via ReceiveAsync returning Close or throwing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 22:30:01 -04:00
gamer147
225c20daeb feat(arena-tk2): PvP pair-up trigger via /do_matching, ?scripted=1 opt-in
Solo pollers park (3001 RETRY); two concurrent pollers pair and both
receive 3004 + same BattleId. Cache hits on the first arriver's next
poll. ?scripted=1 retains today's solo Scripted path for dev work.
Response DTO's BattleId/NodeServerUrl become nullable so 3001 omits
them on the wire (WhenWritingNull policy drops them).

ASP.NET's default bool binder rejects "1" as a value, so the scripted
opt-in is bound as string? and parsed permissively (accepts "1" and
"true"/"True"/etc.) rather than relying on built-in bool binding.
2026-06-01 22:14:04 -04:00
gamer147
28b1d7531a feat(emulated-entrypoint): InProcessPairUp service for TK2 PvP matching
Tiny per-mode FCFS slot. First poller parks; second pairs and triggers
bridge.RegisterBattle(p1, p2, Pvp). Match cached for first poller's
next poll (consume-on-read). No MMR, no cross-mode, no timeouts --
the proper queue API is a separate spec; this is the smallest thing
that lets TK2 PvP work end-to-end.
2026-06-01 22:06:49 -04:00
gamer147
0bb19320df feat(battle-node): WS handler Pvp branch with WaitingRoom
Pvp arrivers Pair-or-Park: second arriver constructs the session;
first arriver awaits self.AwaitSessionFinishedAsync (never calls
self.RunAsync directly because the session does). Park-race retries
Pair once. Bot type still stubbed for Phase 3. Scripted path unchanged.

Viewer-id validation extended to accept either P1 or P2 (PvP sessions
have both).
2026-06-01 22:02:21 -04:00
gamer147
ca5a1e926d feat(battle-node): RealParticipant session-finished signal + Pvp cascade
RealParticipant gains _sessionFinished TCS + MarkSessionFinished /
AwaitSessionFinishedAsync. PvP first-arriver's handler awaits the
signal instead of calling self.RunAsync (which the session does
internally on the same instance — double-call would race the WS read).

BattleSession.RunAsync branches on Type: Pvp uses WhenAny + synthesize
BattleFinish(Win) to survivor + WhenAll(drain); Scripted/Bot keep
Phase 1's WhenAll-everything semantics. Disconnect cascade now drives
end-of-battle when a WS drops without a graceful Retire.
2026-06-01 21:58:47 -04:00
gamer147
2789dc08cb feat(battle-node): WaitingRoom for PvP WS rendezvous
Per-BattleId slot keyed dict. Pair returns the first arriver to the
second; ParkAsync awaits a TCS and returns the second arriver. Timeout
defaults to BattleNodeOptions.WaitingRoomTimeout (60s); evict on timeout
keeps the dict clean. Singleton in DI; consumed by the handler in the
next task.
2026-06-01 21:55:11 -04:00
gamer147
db054205b3 feat(battle-node): PvP TurnEnd broadcast + flipped Retire/Kill result
Pvp TurnEnd/TurnEndFinal broadcasts TurnEnd+Judge to BOTH so each
client's JudgeOperation advances. Pvp Retire/Kill pushes BattleFinish
with flipped result (sender=Lose, other=Win). Scripted Retire keeps
Phase 1 behaviour (sender-only Win via BuildBattleFinishNoContest).
2026-06-01 21:51:27 -04:00
gamer147
72dc1887d9 feat(battle-node): PvP gameplay-frame forwarding arms
TurnStart / PlayActions / Echo / TurnEndActions / JudgeResult from a
real participant in Pvp mode forward to the other participant once
BothAfterReady. Scripted's bot-burst case arms (gated on
FakeOpponentViewerId) precede the PvP forwarder so they're unaffected.

The bot-emission TurnStart/TurnEnd/Judge guard was tightened from
`ReferenceEquals(from, A or B)` (always true) to call
IsRealForwardableFromScripted directly in the `when` clause. The prior
shape used `goto default` to drop non-bot senders, which would have
short-circuited the new PvP forwarder for TurnStart in PvP mode.
2026-06-01 21:46:28 -04:00
gamer147
8a97dd0194 test(battle-node): PvP handshake dispatch sees cross-perspective contexts
Tests assert that for Type=Pvp, A's InitBattle gets Matched with A's
ctx as selfInfo and B's ctx as oppoInfo, and symmetrically for B. Same
for Loaded/BattleStart. Swap stays per-sender (each runs their own
mulligan).
2026-06-01 21:41:35 -04:00
gamer147
875a4baa29 refactor(battle-node): move handshake phase reads to per-participant
ComputeFrames now reads (from as IHasHandshakePhase)?.Phase for the
four handshake arms (InitNetwork, InitBattle, Loaded, Swap) and the
TurnEnd gate, transitioning the participant's Phase instead of the
session's. RealParticipant implements IHasHandshakePhase via the new
Phase property; the session-level BattleSession.Phase stays for the
Terminal short-circuit.

Scripted dispatch + wire shape unchanged (single-Real-participant case
collapses to Phase 1 semantics). Test fixture migrates FakeParticipant
to FakeRealParticipant for the side that drives handshake states. The
bot's TurnEnd previously rode the session-level AfterReady arm; with
that arm now gated on the sender's per-participant Phase (which the
bot lacks), TurnEnd joins TurnStart/Judge in the scripted-bot
forwarder arm so the v1.2 burst still reaches the real participant.
2026-06-01 21:33:17 -04:00
gamer147
ac78473a3e feat(battle-node): add RealParticipant.Phase for per-side handshake state
Internal setter; defaults to AwaitingInitNetwork. PvP needs A and B to
progress through the handshake states independently, which the
session-level BattleSession.Phase can't model. Session migration to read
realFrom.Phase is the next task.
2026-06-01 21:25:11 -04:00
gamer147
b75eb512ea docs(battle-node): refresh ScriptedBotParticipant <remarks> to match Phase 2 wiring
Task 1's refactor made BattleSession read other.Context for the
Matched / BattleStart opponent half, but the class doc still claimed
the Context was ignored. Update it to match the new wiring.
2026-06-01 21:23:09 -04:00
gamer147
560feb231a refactor(battle-node): generalise BuildMatched/BuildBattleStart for PvP
Both helpers now take the opponent's MatchContext + an explicit seed
instead of pulling ScriptedProfiles.OpponentMatchedProfile / OpponentBattleStartProfile
internally. ScriptedBotParticipant.Context fixture absorbs the cosmetic
fields previously hardcoded in ScriptedProfiles so Scripted's wire bytes
stay identical - verified by integration tests still green.

Phase 2 prep: PvP arms will call the same helpers with the real opponent
participant's Context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 21:16:11 -04:00
gamer147
2d7cee38d3 refactor(battle-node): drop old BattleSession; rename V2 -> BattleSession
Old single-WS BattleSession + its dispatch/pump/ClipAckArg tests are
obsolete after the Task 9 handler cutover. ClipAckArg overflow + boundary
coverage moved into RealParticipantTests. BattleSessionV2 renamed back
to BattleSession; the V2 suffix was a placeholder during the parallel
-build refactor.
2026-06-01 20:10:14 -04:00
gamer147
91472df6fc refactor(battle-node): cut handler over to BattleSessionV2 + participants
Production WS path now constructs RealParticipant + ScriptedBotParticipant
and hands them to BattleSessionV2 instead of the old single-WS
BattleSession. Wire behaviour preserved end-to-end (BattleNodeFlowTests
still pass).

Also fixes a RunAsync bug uncovered by the cutover: WhenAny would
terminate the session as soon as the scripted bot's no-op RunAsync
resolved, killing the live WS read loop before any traffic arrived.
Phase 1 semantics are simpler — wait for ALL participants. Phase 2's
Pvp disconnect propagation will revisit this.
2026-06-01 20:07:45 -04:00
gamer147
bbc3a47f7a test(battle-node): BattleSessionV2 dispatch covers Scripted-mode routing
Mirrors v1.2's BattleSessionDispatchTests but asserts on (target, frame,
noStock) routing tuples returned by ComputeFrames. Covers InitNetwork
ack, InitBattle/Loaded/Swap server-synthesized broadcasts (to the real
participant only in Scripted mode), TurnEnd forwarding to the scripted
bot, scripted-bot-emitted frames routing back to the real participant,
Retire/Kill BattleFinish path, and out-of-order frame drops.
2026-06-01 20:03:06 -04:00
gamer147
b2f3d25be0 feat(battle-node): add BattleSessionV2 broker (unused yet)
Parallel to existing BattleSession. Subscribes to both participants'
FrameEmitted, dispatches via ComputeFrames(from, env) returning
(target, frame, noStock) routing tuples. Dispatch table currently only
covers Scripted-mode behaviour (preserves v1.2). Phase 2 adds Pvp arms;
Phase 3 adds Bot. Not yet wired into the handler — Task 9 cuts over.
2026-06-01 20:01:54 -04:00
gamer147
d665f88067 refactor(battle-node): unify IMatchingBridge.RegisterBattle signature
Single RegisterBattle(p1, p2?, type) with contract validation throws on
invalid combinations (Pvp requires both; Bot requires p2==null; Scripted
accepts either). PendingBattle carries Type + P1 + nullable P2. Handler
+ controller adapt; v1.2 behaviour preserved because Scripted is the
only type used today (Phase 2 adds Pvp, Phase 3 adds Bot).
2026-06-01 20:00:52 -04:00
gamer147
acd0997cfb feat(battle-node): add RealParticipant wrapping WS + sequencers
Lifts the WS read loop, SIO encode/decode, per-WS OutboundSequencer +
InboundTracker, and SIO ack out of BattleSession into a participant.
PushAsync(noStock=false) assigns playSeq via the sequencer; noStock=true
bypasses it. FrameEmitted fires on each deduplicated inbound envelope.
The existing BattleSession keeps its own copy of the WS code for now;
Task 9 cuts the handler over to use BattleSessionV2 + RealParticipant
and Task 10 deletes the old BattleSession + duplicate code.
2026-06-01 19:57:45 -04:00
gamer147
fcdcc5d590 feat(battle-node): add ScriptedBotParticipant wrapping v1.2 burst
PushAsync(TurnEnd|TurnEndFinal) fires FrameEmitted three times:
OpponentTurnStart + OpponentTurnEnd + OpponentJudge. Behaviour-identical
to the v1.2 case arm in BattleSession.ComputeResponses; just repackaged
as a participant. Other URIs are swallowed. Used by Phase 1 to preserve
v1.2 behaviour under the new abstraction; replaces the case-arm logic
in BattleSession in Task 7.
2026-06-01 19:56:01 -04:00
gamer147
553a79c795 feat(battle-node): add NoOpBotParticipant
Silent participant for the Phase 3 Bot type. PushAsync swallows;
FrameEmitted never fires; RunAsync completes immediately. ViewerId is
the existing FakeOpponentViewerId const for consistency with scripted
lifecycle builders. Three tests lock the no-op contract.
2026-06-01 19:55:00 -04:00
gamer147
9079715da6 feat(battle-node): add IBattleParticipant interface
Central abstraction for v2 broker. PushAsync (session -> participant),
FrameEmitted (participant -> session), RunAsync (drives inbound),
TerminateAsync (cleanup). Three impls land in Tasks 3-5.
2026-06-01 19:54:03 -04:00
gamer147
ae7ff25af0 feat(battle-node): add BattleType, BattleFinishReason, BattlePlayer
Phase 1 foundation types for the v2 broker architecture. Nothing uses
them yet; they land alongside the existing v1.2 code so subsequent
tasks can extract the participant interface and impls.
2026-06-01 19:53:31 -04:00
gamer147
479548fa56 test(battle-node): integration test expects three frames per cycle
End-to-end exercises the v1.2 burst: each TurnEnd from the client now
produces TurnStart + TurnEnd + Judge through the real WS pump.
2026-06-01 17:42:44 -04:00
gamer147
136149ed6b test(battle-node): wire-shape test for BuildOpponentJudge
Mirrors BuildOpponentTurnEnd_SerializesTurnStateAndResultCode. Guards
JudgeBody's JsonPropertyName keys against rename-induced wire breakage
(per feedback_wire_shape_tests pattern).
2026-06-01 17:40:33 -04:00
gamer147
1ef101f851 feat(battle-node): push Judge after opponent TurnEnd so client transitions
Third frame in the burst, per prod TurnEnd -> Judge pairing observed in
battle-traffic_tk2_regular.ndjson (positions 10->11, 17->18, etc.).
The client's TurnEndOperation sends its own Judge and gates the next turn
on a server-pushed Judge via JudgeOperation -> ControlTurnStartPlayer.
Closes the v1.1 'Opponent's turn... forever' hang caught during smoke.
2026-06-01 17:37:55 -04:00
gamer147
007513e55c test(battle-node): TurnEnd dispatch tests expect three-frame burst (TDD red)
Both single-cycle and consecutive-cycles tests now assert the v1.2
three-frame burst (TurnStart + TurnEnd + Judge). Currently failing —
ComputeResponses still pushes only two frames. Implementation follows.
2026-06-01 17:34:59 -04:00
gamer147
8a5b8b747d feat(battle-node): BuildOpponentJudge builder for v1.2 turn-end Judge
Adds the third frame of the burst. Wire shape from prod (spin + resultCode).
OpponentJudgeSpin const next to OpponentTurnStartSpin for consistency.
Single test locks uri, ViewerId, Cat, and body shape.
2026-06-01 17:32:22 -04:00
gamer147
70b2872589 feat(battle-node): add JudgeBody record for opponent turn-end Judge push
Mirrors OpponentTurnStartBody — JsonPropertyName-cased spin + resultCode
(default 1). First piece of the v1.2 three-frame turn-end burst; nothing
references it yet.
2026-06-01 17:30:00 -04:00
gamer147
5021217134 test(battle-node): wire-shape test + refresh stale comment for v1.1
Final-review follow-ups:
- BuildOpponentTurnStart's doc comment claimed the v1 client sits
  indefinitely — true before the loop closure, false after. Updated
  to describe the pair with BuildOpponentTurnEnd.
- TypedBodyWireShapeTests had no coverage for BuildOpponentTurnEnd;
  added the literal-JSON test so a future JsonPropertyName rename
  on TurnEndBody is caught.
2026-06-01 15:20:48 -04:00
gamer147
ff8e4abea8 test(battle-node): integration test drives two opponent-turn cycles
End-to-end through the real WS pump: after Ready, the test sends two
consecutive TurnEnd msgs and asserts the server pushes
TurnStart+TurnEnd for each. Exercises OutboundSequencer's playSeq
assignment across multiple cycles.
2026-06-01 15:04:21 -04:00
gamer147
decdef29cf test(battle-node): TurnEnd cycle can fire multiple times
Locks the loop invariant: after the first cycle the phase resets to
AfterReady, so the next player TurnEnd matches the same case arm and
produces the same two-frame burst.
2026-06-01 15:01:19 -04:00
gamer147
e30fdb7570 feat(battle-node): scripted opponent turn loop pushes TurnStart + TurnEnd
The TurnEnd/TurnEndFinal case in ComputeResponses now returns two envelopes
back-to-back — opponent TurnStart followed by opponent TurnEnd. Phase enters
OpponentTurn transiently then resets to AfterReady within the same call so
the next player TurnEnd can fire the cycle again. Closes the v1 'stays at
Opponent's turn… forever' stall.
2026-06-01 14:57:49 -04:00
gamer147
96ae090a3a test(battle-node): rewrite TurnEnd dispatch test for two-frame cycle
Replaces the v1.0 single-envelope/OpponentTurn-phase invariant with
the v1.1 two-envelope/AfterReady invariant. Currently failing —
ComputeResponses still does the v1.0 thing. Implementation follows.
2026-06-01 14:53:32 -04:00
gamer147
f24fc7c643 feat(battle-node): BuildOpponentTurnEnd builder for v1.1 turn loop
Pairs with BuildOpponentTurnStart. Wire shape from prod capture
(turnState=0, resultCode=1). Single test locks uri, ViewerId, Cat,
and body shape.
2026-06-01 14:49:52 -04:00
gamer147
d4926e31d6 feat(battle-node): add TurnEndBody record for opponent turn-end push
Mirrors OpponentTurnStartBody — JsonPropertyName-cased turnState +
resultCode (default 1). First piece of the scripted opponent turn-end
loop; nothing references it yet.
2026-06-01 14:46:21 -04:00
gamer147
1904ae4c0c refactor(battle-node): ScriptedLifecycle.InitialHand as ImmutableArray<long>
Audit Md4 cleanup: the prior long[] allowed in-place modification by any
caller with the field reference. ImmutableArray<long> enforces the constant
contract at the type level. ComputeHandAfterSwap uses ToArray() to produce
its mutable working copy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:02:23 -04:00
gamer147
6077844ee8 docs(battle-node): README reflects real drafted deck + cosmetics
Player-side fictions (dummy deck, classId=1) removed; the section now
documents which fields are real (deck, leader, cosmetics) vs still hardcoded
(rank, battlePoint, cardMaster, fieldId, seed) with a pointer to the spec's
§Deferred plumbing for each. "Where to extend" table loses the two done
items and gains a row for wiring future modes via IMatchContextBuilder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:00:22 -04:00
gamer147
e3cc745a61 test(battle-node): end-to-end drafted deck flows into Matched frame
Seeds a viewer + completed TK2 run, drives the WS handshake to Matched, and
asserts every cardId in selfDeck matches the run's SelectedCardIdsJson. Read
from RawBody (codec's wire-form deserialization) — not from MatchedBody —
since the test client gets the JSON-roundtripped envelope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:51:33 -04:00
gamer147
b0488e3f2e feat(battle-node): BuildBattleStart consumes MatchContext for player half
ClassId/CharaId/CardMasterName/BattleType flow from ctx. PlayerBattleStart
Profile removed; Rank/BattlePoint remain as standalone consts pending real
per-viewer rank tracker. One test updated, one new test added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:49:54 -04:00
gamer147
f589283572 feat(battle-node): BuildMatched consumes MatchContext for player half
selfInfo cosmetics + 30-card selfDeck now read from MatchContext. Opponent
half stays in ScriptedProfiles. DummyCardId / BuildDummyDeck / PlayerMatched
Profile removed. Two new tests lock the deck-idx pairing and cosmetic
flow-through; TypedBodyWireShapeTests + lifecycle tests thread a fixture ctx.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:48:04 -04:00
gamer147
01f9bb722a feat(battle-node): thread MatchContext through bridge to BattleSession
IMatchingBridge.RegisterPendingBattle now takes a MatchContext; PendingBattle
carries it; BattleSession stores it. ArenaTwoPickBattleController builds ctx
from IMatchContextBuilder. ScriptedLifecycle still uses ScriptedProfiles for
the player half — Tasks 5/6 migrate the lifecycle.

Existing tests updated: MatchingBridgeTests, BattleNodeFlowTests,
InMemoryBattleSessionStoreTests, BattleSessionDispatchTests, BattleSession
PumpTests, ArenaTwoPickBattleControllerTests (which now seeds a TK2 run +
adds a no-active-run 400 case).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:44:42 -04:00
gamer147
a0fdb0f3c5 feat(match-context): add IMatchContextBuilder TK2 implementation
Assembles MatchContext from ArenaTwoPickRun + viewer cosmetics + config.
Per-mode interface — future modes (rank/free/open-room/...) add one method
each. DI scoped registration. Four tests cover happy path, no-run, incomplete
draft, default-loadout fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:40:26 -04:00
gamer147
89b3d23bde feat(viewer-repo): add LoadForMatchContextAsync for battle-node ctx build
Focused AsNoTracking load with Info.SelectedEmblem/SelectedDegree includes
for the new MatchContextBuilder. Single test locks the include graph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:37:44 -04:00
gamer147
0e8f5427c3 feat(battle-node): add MatchContext record for per-mode player snapshot
Public contract between HTTP-side do_matching controllers (assemble) and
SVSim.BattleNode (consume). First piece of the real-drafted-deck wiring;
nothing references it yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:35:43 -04:00
gamer147
ef3d7bb82b refactor(battle-node): WireConstants for SIO event names + crypto RNG battle id 2026-06-01 11:53:01 -04:00
gamer147
133346e3e8 refactor(battle-node): SocketIoFrame throws on namespace; typed JSON construction 2026-06-01 11:48:17 -04:00
gamer147
2588388d9d refactor(battle-node): distinct WS auth status codes + named handler delegate 2026-06-01 11:45:50 -04:00
gamer147
a364f539ad refactor(battle-node): tighten Phase setter to private; document sid opacity 2026-06-01 11:41:47 -04:00
gamer147
677b1f1392 feat(battle-node): BattleResult enum for BattleFinish.result wire codes 2026-06-01 11:41:16 -04:00
gamer147
eaf6d7160b refactor(battle-node): dedupe NodeCrypto AES setup into BuildAes helper 2026-06-01 11:36:48 -04:00
gamer147
34c4ca0237 fix(battle-node): NodeCrypto.GenerateKey masks rand source with & 0xF 2026-06-01 11:35:53 -04:00
gamer147
e4fbb155e4 test(battle-node): pump-level tests for async-Task dispatch, CT, Md5 clip 2026-06-01 11:15:10 -04:00
gamer147
21b7ddf6ae test(battle-node): TestWebSocket mock for pump-level unit tests 2026-06-01 11:13:54 -04:00
gamer147
4dd61343aa fix(battle-node): clip SIO ack arg instead of checked-cast throwing on overflow 2026-06-01 11:13:24 -04:00
gamer147
453865ade2 fix(battle-node): thread session CT through every send instead of None 2026-06-01 11:12:26 -04:00
gamer147
8cce667e02 fix(battle-node): await DispatchSocketIo instead of async-void fire-and-forget 2026-06-01 11:11:58 -04:00
gamer147
0764b8646f feat(battle-node): capture session-scoped CT in BattleSession.RunAsync 2026-06-01 11:11:31 -04:00
gamer147
e4691d616b fix(battle-node): emit envelope keys before body keys in MsgEnvelope.ToJson
Client RealTimeNetworkAgent.SetNetworkInfo iterates the synchronize-data
dict in insertion order. The "uri" key, when recognized as Matched, calls
GameMgr.InitializeSelfInfo which sets _selfDeck = null. Any "selfDeck"
processed before "uri" gets wiped; Matching.StartBattleLoad then crashes
on null.Select(...). Pre-refactor ToJson built a Dictionary envelope-first
then appended body keys, so the bug never surfaced. The typed-body rewrite
inverted the order — restoring envelope-first matches the prod wire.

Regression test BuildMatched_KeyOrder_PutsUriBeforeSelfDeckAndSelfInfo
locks the contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:53:51 -04:00
gamer147
19cc7980d1 test(battle-node): envelope-level wire-shape regression for scripted bodies 2026-06-01 10:40:54 -04:00
gamer147
5ee270eb16 refactor(battle-node): switch MsgEnvelope.Body to IMsgBody, migrate all sites 2026-06-01 10:40:09 -04:00
gamer147
118be92dc5 feat(battle-node): ScriptedProfiles named constants for scripted bodies 2026-06-01 10:35:45 -04:00
gamer147
c7745d8785 feat(battle-node): typed OpponentTurnStart/ResultCodeOnly/BattleFinish/AlivePush bodies 2026-06-01 10:35:18 -04:00
gamer147
97b9b6fe42 feat(battle-node): typed Deal/Swap/Ready bodies + PosIdx 2026-06-01 10:34:44 -04:00
gamer147
78a6fe93fb feat(battle-node): typed BattleStartBody + Self/Oppo info records 2026-06-01 10:34:07 -04:00
gamer147
d9fbb67f0c feat(battle-node): typed MatchedBody + Self/Oppo info records 2026-06-01 10:33:34 -04:00
gamer147
9217de3aa1 feat(battle-node): add IMsgBody marker + RawBody inbound wrapper 2026-06-01 10:32:44 -04:00
gamer147
c279b811ad docs(battle-node): project README + docstrings on hosting/lifecycle
Add a per-project README in SVSim.BattleNode/ that covers:
- Architecture (the six concern folders)
- The connect-handshake sequence verified end-to-end at smoke
- A wire-format-gotchas table for the spec divergences caught during
  v1 (headers vs query for credentials, schemeless node URL with
  /socket.io/ path, required card_master_id, required resultCode=1,
  Matched in response to InitBattle not InitNetwork, EIO3 0x04 prefix
  on binary frames, FromJson conditional-expression number-boxing)
- What the v1 scripted opponent does and what is hardcoded
- A "where to extend" table for v2 work
- The full test layout and cross-references to specs/plans

Fill in XML docs on the public surface that previously had none:
- BattleNodeExtensions.AddBattleNode / UseBattleNode (DI + middleware
  wiring, including the pipeline-order note that auth runs before
  UseWebSockets)
- BattleNodeWebSocketHandler class + HandleAsync (the validation chain)
- BattleSession.ComputeResponses (the lifecycle state machine, with
  the NoStock flag's meaning)
- ScriptedLifecycle class (v1 scope, resultCode injection rule,
  pointer to the "where to extend" section)
- MatchingBridge class (mint-id + register flow)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:57:15 -04:00
gamer147
9e8ebd1b2b fix(battle-node): preserve long type on numeric array elements in FromJson
Root cause for the lingering mulligan failure: the inline conditional
expression in MsgEnvelope.ToObject

    JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(),

unified its branches to the common implicit-convertible type. long→double
is implicit, so both branches collapsed to double and the integer value
silently widened. Inside an array (idxList:[2]), each element came back
as boxed double; OfType<long> in ExtractIdxList then filtered every
entry out, so swapIndices arrived empty and BuildSwapResponse echoed
the unchanged hand — exactly the diff-against-Deal mismatch the client
flagged as "Card swap failed: AbandonCards[2]/DrawCards[]".

Extract a ParseNumber helper that returns object explicitly so each
branch boxes its own runtime type. Also harden ExtractIdxList to accept
any boxed numeric type (long/int/double/decimal/string) so a future
JSON-parser drift can't silently regress this path again.

Two regression tests:
- FromJson_NumericArray_PreservesLongTypeOnEachElement: confirms the
  fix at the JSON-parse layer with a hardcoded "{\"idxList\":[2,3]}".
- Swap_WithIdxListContainingTwo_ProducesHandWithFreshIdxAtPosition1:
  exercises the dispatch end-to-end with a Body holding a real boxed
  long; asserts position 1 of the response hand is the fresh deck idx 4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:40:50 -04:00
gamer147
77fb93f3ea fix(battle-node): real mulligan card replacement + opponent TurnStart push
Two issues caught during v1 smoke at the mulligan / first-turn boundary:

1) BuildSwapResponse ignored the player's idxList and echoed the same
   3-card hand back. The client diffs the new self[] against the Deal
   to compute "drawn cards" — empty diff against the same hand throws
   "Card swap failed: AbandonCards[X]/DrawCards[]". Replace swapped
   idxs with fresh deck idxs (initial hand was 1/2/3, deck has 4..30
   still available). Same hand must flow into Ready since the client
   diffs again there. Move the hand computation into a new helper
   ComputeHandAfterSwap and have ComputeResponses thread it through
   both BuildSwapResponse and BuildReady.

2) The client doesn't transition to the "Opponent's turn…" display
   on its own after sending TurnEnd — it waits for the server to push
   an opponent TurnStart (per prod TK2 capture line 14). Without it
   the UI just sits on the end-of-turn frame. Add a TurnEnd handler
   that pushes a minimal TurnStart{spin} and transitions to a new
   OpponentTurn phase, which IS the documented v1 stopping point.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:30:44 -04:00
gamer147
e06d97ef6f fix(battle-node): respond to InitBattle/Loaded, not InitNetwork
Pushing Matched in response to InitNetwork lands it before
MatchingInitBattle() finishes wiring up the OnReceivedEvent handler
and setting status=Connect. The client's Matched-case in
ReactionReceiveUri only transitions to StartLoad when status is
Connect at the moment of receipt; otherwise the frame is silently
dropped at the state machine and the matchmaking UI never advances.

The real connect-handshake sequence (per MatchingNetworkConnectChecker
+ Matching.cs):
  1. WS opens.
  2. Client emits InitNetwork (cat=general).
  3. Server replies InitNetwork ack → _initNetworkSuccess = true.
  4. MatchingInitBattle: status=Connect; emit InitBattle; subscribe
     OnReceivedEvent matching handler.
  5. Server replies Matched → status=StartLoad, StartBattleLoad.
  6. Asset load done → client emits Loaded.
  7. Server replies BattleStart + Deal → status=Prepared, GotoBattle.

Add AwaitingInitBattle phase, gate Matched on InitBattle receipt, and
gate BattleStart+Deal on Loaded receipt. Update dispatch and
integration tests to walk the new sequence; InitBattle's wire cat is
Matching(2), not Battle(1).

Caught during v1 smoke walkthrough — battle-traffic.ndjson showed the
client receiving Matched/BattleStart at sub-millisecond gaps after
InitNetwork ack, but never advancing past matchmaking.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 02:08:04 -04:00
gamer147
0b859f1c8e fix(check): merge anonymous resignup viewer into Steam-linked viewer
GameStart already detects the Steam-vs-UDID mismatch produced by
wipe-and-resignup; it now also reclaims the orphan. New
ViewerRepository.MergeAnonymousViewerInto transfers the fresh UDID
from V_new onto V_old in one save (freeing the unique-index slot),
then deletes V_new in a second save. Partial-failure mode is a
benign null-UDID viewer; two rows never contend for the same UDID.
Side benefit: future GetViewerByUdid lookups now short-circuit to
V_old without going through the Steam handler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:59:47 -04:00
gamer147
01b0c64a63 fix(battle-node): inject resultCode=1 into every scripted synchronize push
The client's OnReceived routing drops any synchronize push whose
resultCode != Success(1) — and absent counts as 0(None), which is
also dropped. Our InitNetwork ack and BattleFinish already included
resultCode=1, but the five lifecycle bodies (Matched, BattleStart,
Deal, Swap response, Ready) didn't, so the client silently dropped
every one of them.

Symptom: battle-traffic.ndjson capture showed the client receiving
InitNetwork/Matched/BattleStart, but the UI stayed at the matchmaking
screen until timeout — Matched/BattleStart were dropped at the
routing layer before they ever reached the state machine. Move the
resultCode injection into the shared EnvelopeForPush helper so every
scripted push gets it.

Caught during v1 smoke walkthrough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:55:35 -04:00
gamer147
e7dac31d52 fix(check): emit rewrite_viewer_id when UDID and Steam viewers disagree
Wipe-and-resignup left the client stuck with the blank V_new's id in
Certification.ViewerId. /tool/signup is anonymous, so it can't see the
Steam ticket and creates a fresh anonymous viewer keyed on the new UDID;
the Steam handler on the next request resolves to V_old and serves its
data, but no normal-response hook overwrites Certification.ViewerId.
GameStart now compares the UDID-keyed viewer to the auth-resolved one
and emits rewrite_viewer_id when they differ, which Cute/GameStartCheckTask
writes back into Certification.ViewerId.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:49:56 -04:00
gamer147
cc32223d7d fix(battle-node): strip/prepend EIO3 type byte on binary WS frames
Engine.IO v3 frames over WebSocket prepend the packet-type byte (0x04
for Message) to BINARY frames, the binary analog of the leading digit
on text frames. The real client honors this and our session was
treating the entire binary frame as the Socket.IO attachment payload —
the msgpack decoder saw 0x04 as a positive fixint and failed
deserialization on every inbound msg event.

Symmetric fix: strip 0x04 from inbound binary frames in
BattleSession.RunAsync, prepend 0x04 to outbound binary frames in
EncodeAndSendAsync. RawSocketIoTestClient gets the same on both
directions so the integration test still exercises the same wire
shape as a real client.

Caught during v1 smoke walkthrough, after the WS upgrade started
succeeding (101 Switching Protocols).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:48:52 -04:00
gamer147
ccc9b41473 fix(battle-node): header-based WS detection in auth; split unknown-bid vs mismatch logs
Previous fix used Context.WebSockets.IsWebSocketRequest, but that
requires UseWebSockets() to have already run — and UseBattleNode
(which calls UseWebSockets) is registered AFTER UseAuthentication
in Program.cs, so the WS feature isn't installed when auth runs.
Switch to reading the raw Upgrade header, which works regardless
of middleware order.

Also split the WS handler's "Unknown battle/viewer pair" warning
into two distinct cases so we can tell unknown-BattleId from
viewer-id-mismatch (which lets us see whether the bridge stored
the right viewer or the client is encrypting a different id).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:17:42 -04:00
gamer147
1252f7bd35 fix(battle-node): read WS credentials from headers; skip Steam auth on WS upgrades
Two issues caught in the real-client smoke:

1) BestHTTP's SocketOptions.AdditionalQueryParams puts BattleId and
   viewerId on HTTP request HEADERS for WebSocket-only transport
   (NOT on the URL query string as the in-battle/transport.md spec
   says). Real clients therefore send them as headers; our handler
   was reading from query and rejecting every connect with "Unknown
   battle/viewer pair: <bid>/<garbage>". Fix: header-first, query-
   fallback (so the integration test still works against TestServer).

2) The Steam auth handler was running on every WS upgrade and
   throwing NotSupportedException on Request.Body.Seek (Kestrel's
   HttpRequestStream doesn't support Seek, and a WS upgrade is GET
   with Content-Length: 0 anyway). It flooded logs and added no
   value — the battle node has its own per-connection credentials.
   Skip auth when IsWebSocketRequest is true.

Spec correction for in-battle/transport.md to follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:12:21 -04:00
gamer147
5525dbee24 fix(battle-node): node_server_url matches prod wire format (no scheme, with path)
Prod do_matching captures (data_dumps/captures/traffic_prod_tk2_*) send
the node URL as host:port/socket.io/ with no scheme prefix —
e.g. "node06.shadowverse.jp:13560/socket.io/". BestHTTP's SocketManager
expects this exact shape; the leading ws:// we were sending plus the
missing /socket.io/ path was preventing the client from completing the
post-do_matching connect (eventually times out with "connection timed
out").

Update BattleNodeOptions default, Program.cs override, and both
controller and bridge tests to use "localhost:5148/socket.io/".

Discovered during v1 smoke walkthrough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:06:40 -04:00
gamer147
f765d5c7d4 chore(emulated-entrypoint): quiet EFCore info logs in appsettings 2026-06-01 01:02:32 -04:00
gamer147
9776873073 fix(arena-tk2): include card_master_id in do_matching success response
The decompiled client's DoMatchingBase.SettingCardMasterId calls
jsonData["card_master_id"].ToInt() with no Keys.Contains guard when
matching_state ∈ {3004, 3007, 3011}. Omitting the field crashes the
client with KeyNotFoundException at Cute.NetworkManager+Connect.

Add CardMasterId to DoMatchingResponseDto with a default value of 1
(matching the /load/index response and prod captures). Extend the
controller test to assert the field is present.

Caught during the v1 smoke walk-through; full client log line:
  [Error: Unity Log] KeyNotFoundException: The given key was not
  present in the dictionary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:52:57 -04:00
gamer147
b1397e3a3e refactor(repositories): move static caches into IMemoryCache, enable within-fixture parallelism
BattlePassRepository._curveCache and MissionCatalogRepository._maxLevelCache
were private-static fields populated lazily on first read from whatever
DbContext happened to be in scope. In production "one DbContext lineage
per process" makes that fine. Under parallel test execution each
SVSimTestFactory owns its own SQLite :memory: DB, so the first reader's
DB (often empty, in tests that don't seed BP) poisoned the cache for
concurrent readers from a seeded DB — assertions like "BP level info
must be present after seeding" failed because the process-static cache
returned an empty list populated by the other test's empty DB.

The first patch attempted a `BypassCacheForTests` static flag, which is
exactly the kind of test-only seam that rots the production code: future
caches get the same flag, repos accumulate hidden knobs, and the
underlying invariant ("a cache populated from arbitrary scope serves
arbitrary scope") goes unaddressed.

Instead, move both caches into the DI-registered IMemoryCache.
AddMemoryCache() registers it as singleton-per-service-provider:
production has one provider → one IMemoryCache → identical caching
semantics to before. Each WebApplicationFactory builds its own
provider → its own IMemoryCache → cache is naturally scoped per fixture,
no cross-test bleed possible.

The ResetLevelCurveCache() method and its three call sites
(SVSimTestFactory.SeedGlobalsAsync, BattlePassServiceTests,
LoadControllerTests) are deleted — a fresh factory owns a fresh empty
cache, no manual invalidation needed.

With this and the previous StoryService fixture-instance fix in place,
ParallelScope.All works: 776/776 in 57s wall clock (down from 59s on
Fixtures, 2m13s pre-parallelism).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:41:09 -04:00
gamer147
66c456c1c8 test(story-service): per-test fixture instance + unique InMemoryDb name
NUnit's default FixtureLifeCycle is SingleInstance — every test in a
class shares one fixture instance, so [SetUp]-initialised fields like
_master / _viewer / _service are reset on every test against the same
object. Under serial execution that's fine; under parallel execution
concurrent SetUps wipe each other's Mock setups and the service code
NREs trying to dereference unconfigured stubs.

Compounding it, NewInMemoryDb was being called with nameof(SetUp) which
is the literal string "SetUp", so every test in the fixture also shared
the same EF InMemory database (the provider keys stores by name).

Two fixes:
- [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] on StoryServiceTests
  so each test gets its own instance with its own Mocks.
- Suffix the InMemoryDb name with a Guid so concurrent callers never
  share a store.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:40:51 -04:00
gamer147
31f26655ba test(pack-controller): derive expected active-pack count from seed at runtime
The full-catalog regression test hardcoded "35 active packs as of
2026-05-23" but the controller filters by DateTime.UtcNow against each
pack's commence/complete dates. When two packs (99047, 80047) crossed
their complete_date of 2026-06-01 01:59:59 UTC, the test started
failing with Expected: 35 / But was: 33 — which had been masked all
along by NUnit's trx serializer OOMing on a different test.

The hardcoded count conflated three things that happened to be equal
on the day the test was written: packs in the seed file, packs active
right now, and 35. The test's real intent (per its class docstring) is
"every pack the importer ingests round-trips through /pack/info";
pinning the clock with TimeProvider would solve today's drift but
re-break the moment someone regenerates the seed or retires a pack.

Expected count now derives from the seed file at test time, filtered
by the same predicate the controller uses (PackRepository
.GetActivePacks: IsEnabled && commence <= now <= complete) via the
shared ImporterBase.ParseWireDateTime parser so any date-string quirk
parses identically on both sides. Spot-check on pack 99047 swapped for
"any pack with non-default pack_category" — same schema-fidelity
coverage (non-zero category survives JSON round trip) without pinning
to an id that rotates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:23:15 -04:00
gamer147
7914bab84e test(unit-tests): silence captured stdout in Testing env
The unit-test suite was spending most of its wall clock writing logs.
NUnit captures stdout per test and embeds it in the trx; with HttpLogging
emitting full request/response per controller call, EF Core SQL at
Information level, and ReferenceDataImporter banners running ~500x
(once per factory construction), the trx grew to 3.2 GB and the NUnit
result-XML serializer OOMed in StringBuilder.ToString() — which the
runner reported as one mysteriously failed test, masking a real
date-dependent failure underneath.

Three sources silenced under environment "Testing":
- appsettings.Testing.json drops Default + Microsoft.AspNetCore +
  HttpLoggingMiddleware + EntityFrameworkCore to Warning.
- Program.cs skips app.UseHttpLogging() entirely (avoids the
  middleware overhead, not just the log emission).
- ReferenceDataImporter takes optional TextWriters; the test factory
  passes TextWriter.Null. Per-importer helpers become instance methods
  so they can use the injected writer.

Result on a fresh run with ParallelScope.Fixtures already in place:
- Test duration: 1m46s -> 59s
- Wall clock: 2m23s -> 1m00s
- trx size: 3.2 GB -> 1.7 MB

The previously-masked date-dependent failure (PackControllerFullCatalog
.Info_returns_full_35_pack_catalog_from_production_seed asserting 35
active packs as of 2026-05-23 against a live clock) is now visible and
can be addressed separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:13:42 -04:00
gamer147
d093d872ae test(unit-tests): parallelize at the fixture level
NUnit's default ParallelScope is Self (serial). With ~736 tests each
constructing its own SVSimTestFactory (full ASP.NET host + SQLite :memory:
+ ReferenceDataImporter seeding 7270 rows from CSVs), the suite was
running ~2m13s serial. ParallelScope.Fixtures drops it to ~1m46s — a
~20% wall-clock reduction with zero new failures.

Stayed at Fixtures rather than All because ParallelScope.All exposes
the process-static BattlePassRepository._curveCache (and likely other
similar caches) to races inside heavy-globals fixtures (LoadController,
PackControllerFullCatalog, StoryService — all consistent failures
under All, flaky 3-7 fails across runs). Within-fixture parallelism
is blocked on cleaning those up first.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:06:47 -04:00
gamer147
905fdc780a test(battle-node): end-to-end flow test through Ready via WebApplicationFactory
Boots SVSimTestFactory (in-memory SQLite + reference-data CSV import),
mints a battle via IMatchingBridge, opens a raw Socket.IO v2 client
against the in-process TestServer, drives InitNetwork → Loaded → Swap,
and asserts the right scripted frames come back in order.

Verifies the full transport stack end-to-end: EIO3+SIO2 framing,
encryptForNode codec, MsgPayloadCodec roundtrip, InboundTracker
pubSeq dedup + ack echo, OutboundSequencer playSeq assignment, and
ScriptedLifecycle's Path-A frame builders.

Note: RawSocketIoTestClient.DisposeAsync skips the graceful CloseAsync
handshake — TestServer's in-process WebSocket implementation can hang
on it. Abrupt Dispose is fine: the server's ReceiveAsync throws
WebSocketException, BattleSession.RunAsync returns, and the handler
completes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:37:31 -04:00
gamer147
ff51c33b6c feat(arena-tk2): do_matching mints battle via IMatchingBridge, returns 3004 2026-05-31 22:53:20 -04:00
gamer147
88ed8254af feat(emulated-entrypoint): wire AddBattleNode + UseBattleNode into the web host 2026-05-31 22:49:31 -04:00
gamer147
1dd6a70e8d feat(battle-node): WebSocket endpoint at /socket.io/ + DI extension methods 2026-05-31 22:34:54 -04:00
gamer147
f19da481c3 fix(battle-node): MatchingBridge avoids Math.Abs(int.MinValue) overflow
Cast GetHashCode() result to long before Math.Abs to prevent OverflowException
on the ~1-in-4B case where GetHashCode returns int.MinValue. Adds a regression
test pinning the 12-digit decimal format end-to-end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:33:35 -04:00
gamer147
d3c4b3083e feat(battle-node): IMatchingBridge + MatchingBridge mint battle id + node url 2026-05-31 22:31:04 -04:00
gamer147
680630050b fix(battle-node): BattleSession crash safety, fresh-key per push, phase guards
- Wrap HandleMsgEventAsync / HandleAliveEventAsync bodies in try/catch(Exception)
  logging at Error, eliminating async-void unobserved-exception crash risk (Issue 1).
- Replace deterministic seq-based key generator with RandomNumberGenerator.GetInt32
  so each EncodeAndSendAsync call uses a fresh random key (Issue 2).
- Add `when Phase == …` guards to InitNetwork / Loaded / Swap cases in
  ComputeResponses; add default arm that logs+drops out-of-order URIs (Issue 3).
- Widen SendSioAckAsync arg from int to long; drop (int) cast at call site;
  boundary cast to int is now checked() for defensive overflow detection (Issue 4).
- Update RunAsync doc comment (was stale Task-13 placeholder) (Issue 5).
- Add Kill and out-of-order-Swap-before-Loaded tests (Issue 6).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:28:13 -04:00
gamer147
f6aee5b0f8 feat(battle-node): BattleSession routes lifecycle URIs through ScriptedLifecycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:21:55 -04:00
gamer147
30b457c9a0 fix(battle-node): assert Bid is in envelope (not Body) on BuildMatched
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:18:12 -04:00
gamer147
0fd4f5f9f7 feat(battle-node): ScriptedLifecycle frame builders (Path-A static opponent) 2026-05-31 22:15:44 -04:00
gamer147
a306295fe2 feat(battle-node): BattleSession skeleton with EIO/SIO read pump 2026-05-31 22:10:17 -04:00
gamer147
22a4825265 feat(battle-node): Gungnir alive-body builders (scs/ocs ONLINE placeholders) 2026-05-31 22:07:31 -04:00
gamer147
82b7d1e940 feat(battle-node): OutboundSequencer assigns playSeq + archives for Resume 2026-05-31 22:05:16 -04:00
gamer147
87051737da feat(battle-node): InboundTracker dedupes client pubSeq + tracks high-water 2026-05-31 22:02:56 -04:00
gamer147
3ade8ff4f5 feat(battle-node): in-memory IBattleSessionStore + PendingBattle 2026-05-31 22:00:40 -04:00
gamer147
c0c2bb5772 feat(battle-node): MsgPayloadCodec encodes/decodes msgpack↔envelope chain 2026-05-31 21:58:06 -04:00
gamer147
4cc8b3c01c fix(battle-node): MsgEnvelope rejects reserved Body keys + complete ReceiveNodeResultCode
ToJson now throws ArgumentException when a Body key collides with a reserved
envelope field (uri/viewerId/uuid/bid/try/cat/pubSeq/playSeq); FromJson reuses
the same shared ReservedEnvelopeKeys HashSet. ReceiveNodeResultCode expanded
from 9 to 31 codes to mirror the full enums.md catalog. Two regression tests
added for the collision guard and PascalCase uri serialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:55:11 -04:00
gamer147
383044dd8f feat(battle-node): NetworkBattleUri / EmitCategory enums and MsgEnvelope record
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:50:17 -04:00
gamer147
6ff4f70f1a fix(battle-node): SocketIoFrame disposal safety + escaping + empty-args encoding
- Wrap all JsonDocument.Parse calls in using blocks and Clone() each
  retained JsonElement to eliminate UAF hazard after GC.
- Use JsonSerializer.Serialize with UnsafeRelaxedJsonEscaping so event
  names with " or \ produce \" / \ rather than " / plain \;
  avoids malformed JSON on Encode().
- Guard the [ ] block in Encode() behind EventName-or-args check so
  Connect/Disconnect packets round-trip as bare "0"/"1" not "0[]".
- Add three regression tests: Connect no-bracket, Event round-trip,
  special-char event name escaping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:46:02 -04:00
gamer147
8b1f613407 feat(battle-node): SocketIoFrame parse/encode for SIO2 incl. binary attachments 2026-05-31 21:39:53 -04:00
gamer147
6c6664f011 feat(battle-node): EngineIoFrame parse/encode for EIO3 packets 2026-05-31 21:34:11 -04:00
gamer147
a786599416 fix(battle-node): clarify NodeCrypto.GenerateKey contract + add fixed-vector regression test
Replace inaccurate GenerateKey docstring (it claimed to port Cryptographer.generateKeyString
directly but the input shape differs: server uses one hex digit per call, client uses
Random.Next(0,65535) per call). New doc is honest about the difference and explains why
it's safe. Add EncryptForNode_FixedVector_ProducesStableOutput: a pinned AES-CBC vector
that catches encoding/IV/padding regressions that would slip past the roundtrip test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:31:22 -04:00
gamer147
0a2eddd920 feat(battle-node): port AES-256-CBC encryptForNode/decryptForNode codec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:26:05 -04:00
gamer147
50790a706c feat(battle-node): scaffold SVSim.BattleNode class library 2026-05-31 21:21:14 -04:00
gamer147
dd231b081d Merge branch 'inventory-service'
InventoryService consolidation: replaces RewardGrantService,
CurrencySpendService, ViewerEntitlements, and CardAcquisitionService
with a single scoped-transaction facade IInventoryService.

- BeginAsync loads viewer with canonical inventory graph + extras
- TrySpendAsync/TryDebitAsync/GrantAsync queue ops; CommitAsync saves
- Result carries RewardList (post-state, currency-collision-resolved)
  + Deltas (verbatim queued) for distinct wire fields
- Freeplay logic folded into the tx surface
- 14 callers ported (Load, BuildDeck, Pack, LeaderSkin, Sleeve,
  ItemPurchase, SpotCardExchange, Gift, Achievement, Puzzle, Story,
  BattlePass, ArenaTwoPick, GachaPoint); CardInventoryRepository.Create
  ported, Destruct deferred
- 8 old service files + 4 test files deleted
- 713/713 tests pass

Spec: docs/superpowers/specs/2026-05-31-inventory-service-design.md
Plan: docs/superpowers/plans/2026-05-31-inventory-service.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:25:43 -04:00
gamer147
a033bf361a fix(battle-pass): remove redundant SaveChanges after CommitAsync
CommitAsync's inner SaveChangesAsync already flushes the AddClaim
rows + progress.IsPremium mutation alongside the inventory grants
(same scoped DbContext). The trailing _db.SaveChangesAsync was a
no-op in BuyPremium and only meaningful in AddPoints when no level
crossed (no tx opened) — restructured to an else branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:48:26 -04:00
gamer147
2ee40c6df7 test(inventory): wire-shape regression for spend+grant+cascade
Serializes result.RewardList with snake_case+WhenWritingNull options and
asserts the three entries come out in expected first-touch order:
Crystal post-state (500), Card post-state count (3), Sleeve cascade (1).
Also verifies snake_case key names are actually emitted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 17:12:16 -04:00
gamer147
2c62a7be80 refactor(inventory): delete old primitives after InventoryService cutover
Removed RewardGrantService, CurrencySpendService, ICurrencySpendService,
ViewerEntitlements, IViewerEntitlements, CardAcquisitionService,
ICardAcquisitionService, CardGrantResult and their tests
(RewardGrantServiceTests, CurrencySpendServiceTests,
CardAcquisitionServiceTests, ViewerEntitlementsTests). Removed four DI
registrations from Program.cs. No caller references any deleted type;
GrantedReward and EffectiveCosmetics were pre-moved to InventoryGrantTypes.cs
in the prior commit. Build clean, 712/712 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 17:07:30 -04:00
gamer147
df0e132459 refactor(inventory): move GrantedReward + EffectiveCosmetics into Inventory namespace folder
Both types stay in namespace SVSim.Database.Services so existing using directives
in controllers, services, and tests resolve without change. Their definitions are
extracted to SVSim.Database/Services/Inventory/InventoryGrantTypes.cs; the empty
husks in RewardGrantService.cs and IViewerEntitlements.cs will be deleted in the
next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 17:03:06 -04:00
gamer147
c37c04c1b7 refactor(gacha-point): route TryExchangeAsync through IInventoryTransaction
Change signature from (Viewer, packId, cardId) to (IInventoryTransaction, packId, cardId).
Drop RewardGrantService from GachaPointService ctor. PackController.ExchangeGachaPoint opens
tx with GachaPointBalances/Received extra includes, passes tx, commits on success.
Update GachaPointServiceTests to use inv.BeginAsync + tx pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:55:08 -04:00
gamer147
b6bf9b7495 refactor(arena-two-pick): route entry/finish through InventoryService
Replace RewardGrantService + ICurrencySpendService + IViewerEntitlements with
IInventoryService. tx.IsFreeplay replaces FakeEntitlements.IsFreeplay; debit
helpers take IInventoryTransaction. ComputePostStateRewardList deleted (replaced
by result.RewardList from CommitAsync). Update 5 test files to new 8-arg ctor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:51:03 -04:00
gamer147
26bc4fe2ab refactor(battle-pass): route BuyPremiumAsync and AddPointsAsync through InventoryService
Replace RewardGrantService + ICurrencySpendService with IInventoryService tx.
CommitAsync's currency-collision rule replaces the manual Crystal RemoveAll+re-append
scrub in BuyPremiumAsync. AddPointsAsync uses result.Deltas for NewlyClaimed to
preserve per-track visibility (two Rupy grants stay two entries).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:46:13 -04:00
gamer147
7c4bc2966f refactor(story): route FinishAsync rewards through InventoryService
Replace RewardGrantService with IInventoryService tx. Per-reward GrantAsync
calls inside try/catch preserve the NotSupportedException skip; CommitAsync
returns result.RewardList (post-state totals) and accumulated delta list feeds
story_reward_list. Update StoryServiceTests to inject IInventoryService.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:42:38 -04:00
gamer147
a310697830 refactor(puzzle): route finish rewards through InventoryService
Replace RewardGrantService + HttpContext.RequestServices viewer load with
IInventoryService tx. Single BeginAsync/GrantAsync/CommitAsync wraps all
mission rewards on the win path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:40:16 -04:00
gamer147
4ba7d8f6d0 refactor(achievement): route receive_reward through InventoryService
Replace RewardGrantService with IInventoryService tx. EnsureCurrentAsync
still runs before BeginAsync to avoid EF concurrent-context conflicts;
tx.Viewer replaces the manually loaded viewer graph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:39:07 -04:00
gamer147
369edd4537 refactor(gift): route tutorial gift_receive through InventoryService
Replace RewardGrantService with IInventoryService tx. GrantAsync returns
post-state totals directly, eliminating the manual ResolvePostStateRewardNum
helper. MissionData loaded via extra include on BeginAsync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:37:53 -04:00
gamer147
a2cec7c99e refactor(spot-card-exchange): route through InventoryService
Replace RewardGrantService + ICurrencySpendService with IInventoryService
tx pattern. BeginAsync loads viewer, TrySpendAsync debits SpotPoint,
GrantAsync grants card + cascade, CommitAsync saves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:35:53 -04:00
gamer147
ad4d4e0646 refactor(item-purchase): route through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:26:34 -04:00
gamer147
9436a0d21b refactor(sleeve): route buy through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:25:10 -04:00
gamer147
45fa3d75bf refactor(leader-skin): route shop through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:23:50 -04:00
gamer147
4d6da23443 refactor(pack): route Open through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:20:23 -04:00
gamer147
57dd524d9f refactor(build-deck): route Buy through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:17:31 -04:00
gamer147
61013fcf5c refactor(card-inventory): route Create/Destruct through InventoryService
RedEther debit now goes through tx.TrySpendAsync (freeplay-aware);
Card grants route through tx.GrantAsync (cosmetic cascade for first-time
owners). Validation phase unchanged. DestructCards left on direct-viewer
path (structural mismatch: validation on one viewer, mutation on same
instance — clean tx port deferred to follow-up).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:15:40 -04:00
gamer147
1113e52f94 refactor(load): switch to InventoryService for entitlements
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:12:27 -04:00
gamer147
91909c5755 feat(inventory): read-side methods on IInventoryService + tx
EffectiveBalance/OwnsCard/OwnsCosmetic on the tx are freeplay-aware
against the live viewer. EffectiveOwnedCardsAsync/EffectiveCosmeticsAsync
on the service mirror today's ViewerEntitlements projections (used by
/load/index).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:05:35 -04:00
gamer147
ea340cde21 test(inventory): lifecycle — dispose rollback + use-after-commit
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:03:06 -04:00
gamer147
b0b9901c42 feat(inventory): CommitAsync + currency-collision rule
Last post-state per currency wins; non-currency grants collapse to final
count per (type, id). Deltas are verbatim queued, no cascade. SaveChanges
+ DB tx commit happen atomically inside Commit; failure leaves rollback
to DisposeAsync. CS0649 warning on _committed is now resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:02:39 -04:00
gamer147
1ba3f57709 feat(inventory): BackfillCardCosmeticsAsync
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:01:15 -04:00
gamer147
46d8239d5a feat(inventory): TryDebitAsync dispatches currencies + Item
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:00:24 -04:00
gamer147
301da9eeca feat(inventory): TrySpendAsync covers all 4 wallets + freeplay
Crystal/Rupy/RedEther freeplay no-op (returns configured amount,
balance unchanged); SpotPoint always real. Insufficient returns
current balance; success returns post-deduction balance.
SVSimTestFactory gains freeplayEnabled ctor overload that upserts
the Freeplay GameConfigSection row after EnsureSeedData.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:59:26 -04:00
gamer147
a821b7f6b4 feat(inventory): GrantAsync handles Card + cosmetic cascade
Card grants produce a post-state-total entry and run the CardCosmeticReward
cascade (foil twin → id-1 lookup). Cascade additions are skipped when the
viewer already owns the cosmetic; missing-master-row failures are logged
and dropped without failing the parent grant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:54:36 -04:00
gamer147
1f3f81d878 feat(inventory): GrantAsync handles Item branch
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:52:42 -04:00
gamer147
a1cf1d7519 feat(inventory): GrantAsync handles cosmetic branches
Sleeve/Emblem/Skin/Degree/MyPageBG grants are idempotent on the viewer's
owned-collection but always emit a wire entry at the top level (preserves
"+1 sleeve" purchase popup). Unknown ids throw InventoryCatalogException.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:51:46 -04:00
gamer147
3bc38b407b feat(inventory): GrantAsync handles currency branches
Crystal/Rupy/RedEther/SpotCardPoint grants mutate ViewerCurrency in place
and emit post-state-total wire entries. Op log records the post-state for
later currency-collision resolution in CommitAsync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:50:27 -04:00
gamer147
02e86cf16c feat(inventory): BeginAsync loads viewer with canonical graph
Includes Cards/Sleeves/Emblems/LeaderSkins/Degrees/MyPageBackgrounds/Items
under AsSplitQuery, plus caller-supplied extras via InventoryLoadConfig.
Opens a DB transaction and returns an InventoryTransaction shell. All
mutation methods throw NotImplementedException until subsequent tasks
land them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:46:20 -04:00
gamer147
b181257aaa docs(inventory): XML docs for TrySpend/TryDebit/EffectiveBalance
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:42:41 -04:00
gamer147
220e5699cd feat(inventory): scaffold InventoryService namespace types
Empty interfaces + records for IInventoryService, IInventoryTransaction,
InventoryCommitResult, InventoryLoadConfig, InventoryCatalogException.
Implementation lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:38:51 -04:00
4085 changed files with 525541 additions and 5571 deletions

4
.gitignore vendored
View File

@@ -408,4 +408,6 @@ FodyWeavers.xsd
*.msp
# JetBrains Rider
*.sln.iml
*.sln.iml
# Stale editor backups
*.bak

View File

@@ -9,6 +9,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.UnitTests", "SVSim.Un
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.Bootstrap", "SVSim.Bootstrap\SVSim.Bootstrap.csproj", "{666786D9-9A4D-49EA-A759-39055C57F9AA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.BattleNode", "SVSim.BattleNode\SVSim.BattleNode.csproj", "{F4549DD3-566A-4155-8D52-3A4D2A7072F7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.BattleEngine", "SVSim.BattleEngine\SVSim.BattleEngine.csproj", "{CCE23D9D-6A66-456B-9812-F09B1FDA3C81}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.BattleEngine.Tests", "SVSim.BattleEngine.Tests\SVSim.BattleEngine.Tests.csproj", "{68F3F596-CAD5-4326-8779-AD8C7BD20CDA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -31,5 +37,17 @@ Global
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Release|Any CPU.Build.0 = Release|Any CPU
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Release|Any CPU.Build.0 = Release|Any CPU
{CCE23D9D-6A66-456B-9812-F09B1FDA3C81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CCE23D9D-6A66-456B-9812-F09B1FDA3C81}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CCE23D9D-6A66-456B-9812-F09B1FDA3C81}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CCE23D9D-6A66-456B-9812-F09B1FDA3C81}.Release|Any CPU.Build.0 = Release|Any CPU
{68F3F596-CAD5-4326-8779-AD8C7BD20CDA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{68F3F596-CAD5-4326-8779-AD8C7BD20CDA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{68F3F596-CAD5-4326-8779-AD8C7BD20CDA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{68F3F596-CAD5-4326-8779-AD8C7BD20CDA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,7 @@
// Each engine-state fixture wraps its tests in a TestBattleScope, so AsyncLocal ambient
// isolates per-test state (mgr/GameMgr/IsForecast/IsRandomDraw/RecoveryInfo/etc.). The
// residual process-globals (Unity Resources shim cache, Wizard.LocalLog accumulators) are
// now thread-safe (ConcurrentDictionary / static lock), so fixtures can run in parallel.
using NUnit.Framework;
[assembly: Parallelizable(ParallelScope.Fixtures)]

View File

@@ -0,0 +1,246 @@
#nullable enable
using SVSim.BattleEngine.Ambient;
using NUnit.Framework;
using System.Runtime.Serialization;
using System.Threading.Tasks;
namespace SVSim.BattleEngine.Tests;
[TestFixture, Parallelizable(ParallelScope.Self)]
public class BattleAmbientTests
{
[Test]
public void Current_IsNull_WhenNoScope()
{
Assert.That(BattleAmbient.Current, Is.Null);
}
[Test]
public void Require_Throws_WhenNoScope()
{
Assert.Throws<System.InvalidOperationException>(() => BattleAmbient.Require());
}
[Test]
public void Enter_SetsCurrent_RestoresOnDispose()
{
var ctx = new BattleAmbientContext { ViewerId = 42 };
Assert.That(BattleAmbient.Current, Is.Null);
using (var _ = BattleAmbient.Enter(ctx))
{
Assert.That(BattleAmbient.Current, Is.SameAs(ctx));
Assert.That(BattleAmbient.Require().ViewerId, Is.EqualTo(42));
}
Assert.That(BattleAmbient.Current, Is.Null);
}
[Test]
public void Enter_Nested_RestoresPriorOnDispose()
{
var outer = new BattleAmbientContext { ViewerId = 1 };
var inner = new BattleAmbientContext { ViewerId = 2 };
using (var _o = BattleAmbient.Enter(outer))
{
Assert.That(BattleAmbient.Current!.ViewerId, Is.EqualTo(1));
using (var _i = BattleAmbient.Enter(inner))
{
Assert.That(BattleAmbient.Current!.ViewerId, Is.EqualTo(2));
}
Assert.That(BattleAmbient.Current!.ViewerId, Is.EqualTo(1));
}
}
[Test]
public async Task Enter_FlowsAcrossAwait()
{
var ctx = new BattleAmbientContext { ViewerId = 99 };
using (var _ = BattleAmbient.Enter(ctx))
{
await Task.Yield();
Assert.That(BattleAmbient.Current, Is.SameAs(ctx));
}
}
[Test]
public async Task Enter_IsolatedBetweenConcurrentTasks()
{
var ctxA = new BattleAmbientContext { ViewerId = 100 };
var ctxB = new BattleAmbientContext { ViewerId = 200 };
var taskA = Task.Run(async () => {
using var _ = BattleAmbient.Enter(ctxA);
await Task.Delay(20);
return BattleAmbient.Current!.ViewerId;
});
var taskB = Task.Run(async () => {
using var _ = BattleAmbient.Enter(ctxB);
await Task.Delay(20);
return BattleAmbient.Current!.ViewerId;
});
var results = await Task.WhenAll(taskA, taskB);
Assert.That(results[0], Is.EqualTo(100));
Assert.That(results[1], Is.EqualTo(200));
}
[Test]
public void IsForecast_ReadsAmbient_WhenScopeActive()
{
var ctx = new BattleAmbientContext { IsForecast = false };
using var _ = BattleAmbient.Enter(ctx);
Assert.That(BattleManagerBase.IsForecast, Is.False);
ctx.IsForecast = true;
Assert.That(BattleManagerBase.IsForecast, Is.True);
}
[Test]
public void IsForecast_WriteInsideScope_WritesAmbient_NotFallback()
{
var ctx = new BattleAmbientContext { IsForecast = false };
using (var _ = BattleAmbient.Enter(ctx))
{
BattleManagerBase.IsForecast = true;
Assert.That(ctx.IsForecast, Is.True);
}
}
[Test]
public void IsForecast_OutsideScope_GetAndSetThrow()
{
// Post-Task-8: fallback is gone. Both get and set go through BattleAmbient.Require(),
// which throws when no scope is active. This is the forcing function — any unwrapped
// engine code that touches IsForecast fails fast instead of silently writing a static.
Assert.That(BattleAmbient.Current, Is.Null);
Assert.Throws<System.InvalidOperationException>(() => { var _ = BattleManagerBase.IsForecast; });
Assert.Throws<System.InvalidOperationException>(() => BattleManagerBase.IsForecast = true);
}
[Test]
public void IsRandomDraw_OutsideScope_GetAndSetThrow_InsideScope_Roundtrips()
{
// Post-Task-8: get/set both Require() a scope. Inside a scope, writes land on the ctx.
Assert.That(BattleAmbient.Current, Is.Null);
Assert.Throws<System.InvalidOperationException>(() => { var _ = BattleManagerBase.IsRandomDraw; });
Assert.Throws<System.InvalidOperationException>(() => BattleManagerBase.IsRandomDraw = true);
var ctx = new BattleAmbientContext { IsRandomDraw = false };
using (var _ = BattleAmbient.Enter(ctx))
{
Assert.That(BattleManagerBase.IsRandomDraw, Is.False);
BattleManagerBase.IsRandomDraw = true;
Assert.That(ctx.IsRandomDraw, Is.True);
}
// Scope disposed -> back to throwing on access.
Assert.Throws<System.InvalidOperationException>(() => { var _ = BattleManagerBase.IsRandomDraw; });
}
[Test]
public void GetIns_ReadsAmbient_WhenScopeActive()
{
var fakeMgr = (BattleManagerBase)System.Runtime.Serialization
.FormatterServices.GetUninitializedObject(typeof(BattleManagerBase));
var ctx = new BattleAmbientContext { Mgr = fakeMgr };
using var _ = BattleAmbient.Enter(ctx);
Assert.That(BattleManagerBase.GetIns(), Is.SameAs(fakeMgr));
}
[Test]
public void GetIns_OutsideScope_ReturnsNull()
{
// Post-Task-8: fallback is gone. GetIns() reads Current?.Mgr (soft, kept null-tolerant so
// engine call sites that pattern `GetIns()?.Foo ?? default` still compose). With no scope
// active, Current is null, so GetIns() returns null.
Assert.That(BattleAmbient.Current, Is.Null);
Assert.That(BattleManagerBase.GetIns(), Is.Null);
}
[Test]
public void ViewerId_ReadsAmbient_WhenScopeActive()
{
var ctx = new BattleAmbientContext { ViewerId = 12345 };
using var _ = BattleAmbient.Enter(ctx);
Assert.That(Cute.Certification.ViewerId, Is.EqualTo(12345));
}
[Test]
public void RealTimeNetworkAgent_ReadsAmbient_WhenScopeActive()
{
var ctx = new BattleAmbientContext();
using var _ = BattleAmbient.Enter(ctx);
Assert.That(Wizard.ToolboxGame.RealTimeNetworkAgent, Is.Null);
var agent = (RealTimeNetworkAgent)System.Runtime.Serialization
.FormatterServices.GetUninitializedObject(typeof(RealTimeNetworkAgent));
ctx.NetworkAgent = agent;
Assert.That(Wizard.ToolboxGame.RealTimeNetworkAgent, Is.SameAs(agent));
}
[Test]
public void SetRealTimeNetworkBattle_InsideScope_WritesAmbient()
{
var ctx = new BattleAmbientContext();
using var _ = BattleAmbient.Enter(ctx);
var agent = (RealTimeNetworkAgent)System.Runtime.Serialization
.FormatterServices.GetUninitializedObject(typeof(RealTimeNetworkAgent));
Wizard.ToolboxGame.SetRealTimeNetworkBattle(agent);
Assert.That(ctx.NetworkAgent, Is.SameAs(agent));
}
[Test]
public void BattleRecoveryInfo_ReadsAmbient_WhenScopeActive()
{
var info = (Wizard.BattleRecoveryInfo)System.Runtime.Serialization
.FormatterServices.GetUninitializedObject(typeof(Wizard.BattleRecoveryInfo));
var ctx = new BattleAmbientContext { RecoveryInfo = info };
using var _ = BattleAmbient.Enter(ctx);
Assert.That(Wizard.Data.BattleRecoveryInfo, Is.SameAs(info));
}
[Test]
public void BattleRecoveryInfo_SetInsideScope_WritesAmbient()
{
var ctx = new BattleAmbientContext();
using var _ = BattleAmbient.Enter(ctx);
var info = (Wizard.BattleRecoveryInfo)System.Runtime.Serialization
.FormatterServices.GetUninitializedObject(typeof(Wizard.BattleRecoveryInfo));
Wizard.Data.BattleRecoveryInfo = info;
Assert.That(ctx.RecoveryInfo, Is.SameAs(info));
}
[Test]
public void GameMgr_GetIns_InsideScope_ReturnsScopeInstance()
{
var mgr = new GameMgr();
var ctx = new BattleAmbientContext { GameMgr = mgr };
using var _ = BattleAmbient.Enter(ctx);
Assert.That(GameMgr.GetIns(), Is.SameAs(mgr));
}
[Test]
public void GameMgr_GetIns_OutsideScope_Throws()
{
Assert.That(BattleAmbient.Current, Is.Null);
Assert.Throws<System.InvalidOperationException>(() => GameMgr.GetIns());
}
[Test]
public async Task GameMgr_GetIns_IsolatedBetweenConcurrentTasks()
{
var mgrA = new GameMgr();
var mgrB = new GameMgr();
var taskA = Task.Run(async () => {
using var _ = BattleAmbient.Enter(new BattleAmbientContext { GameMgr = mgrA });
await Task.Delay(20);
return GameMgr.GetIns();
});
var taskB = Task.Run(async () => {
using var _ = BattleAmbient.Enter(new BattleAmbientContext { GameMgr = mgrB });
await Task.Delay(20);
return GameMgr.GetIns();
});
var results = await Task.WhenAll(taskA, taskB);
Assert.That(results[0], Is.SameAs(mgrA));
Assert.That(results[1], Is.SameAs(mgrB));
}
}

View File

@@ -0,0 +1,56 @@
using NUnit.Framework;
using UnityEngine;
using Wizard.Battle.View;
namespace SVSim.BattleEngine.Tests
{
// Regression for the in-play metamorphose NRE diagnosed 2026-06-07 (bid 283192092460).
//
// The IsRecovery card-create delegate (NetworkBattleManagerBase.cs:379) passes null for the
// cardGameObject, which left BattleCardView.GameObject null. Skill_metamorphose.cs:147 in the
// IsInplay branch then NRE'd on the unguarded
// metamorphosedCard.BattleCardView.GameObject.transform.rotation = Quaternion.identity
// — a purely cosmetic transform reset that has no corresponding state mutation, but tripped over
// null-GameObject before the surrounding mutations (ReplaceInPlay, SetUpInplay,
// FlagCardAsDestroyedBySkill, RemoveFromInPlay) could complete.
//
// Fix: ViewUiTouchStubs.cs's BattleCardView.GameObject is now lazily non-null (matches the
// existing Component.gameObject pattern at UnityShim.cs:94). The shim materializes a no-op
// GameObject on first read; the cosmetic touch resolves to a no-op assignment instead of NRE.
[TestFixture]
public class BattleCardViewShimTests
{
[Test]
public void GameObject_is_lazily_non_null_so_unguarded_recovery_touches_no_op()
{
var view = new BattleCardView();
Assert.That(view.GameObject, Is.Not.Null,
"BattleCardView.GameObject must be lazily non-null in the shim so unguarded " +
"Unity touches on the IsRecovery card-create path (which passes null cardGameObject) " +
"resolve to no-ops instead of NRE-ing.");
Assert.That(view.GameObject.transform, Is.Not.Null,
"GameObject.transform must follow the shim's lazy non-null Component pattern (UnityShim.cs:94).");
Assert.DoesNotThrow(() => view.GameObject.transform.rotation = Quaternion.identity,
"Skill_metamorphose.cs:147's cosmetic transform.rotation reset on the in-play branch must " +
"not throw in the headless IsRecovery path (live bid 283192092460: A's Petrification " +
"on B's in-play card).");
}
[Test]
public void GameObject_is_stable_across_reads_so_a_set_followed_by_read_returns_the_same_instance()
{
// Lazy materialization caches the GameObject on first read, so subsequent reads return
// the same instance — required for any code path that reads .GameObject, mutates it,
// and reads again (e.g. follower position/rotation/scale set in sequence).
var view = new BattleCardView();
var first = view.GameObject;
var second = view.GameObject;
Assert.That(second, Is.SameAs(first),
"lazy GameObject must cache; otherwise the second read returns a fresh instance " +
"and any mutation on the first read is lost.");
}
}
}

View File

@@ -0,0 +1,105 @@
using System.Reflection;
using NUnit.Framework;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M4 (next-hardest deterministic card): a when_play SELF-BUFF follower resolves to correct
// authoritative state HEADLESS via the same IsForecast/IsRecovery + ActionProcessor path the M2
// vanilla follower and M3 fixed-damage spell proved (design §5 / DP4 + M3 resume recipe). The new
// oracle dimension over M2/M3 is the PLAYED CARD'S OWN STAT DELTA: the fanfare `powerup`
// `add_offense=1&add_life=1` to `target=self` must raise the follower's Atk and Life by exactly
// those amounts over its CardCSVData base — a self-buff, so no target selection is involved.
[TestFixture]
public class BuffFollowerOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
private static void SetPrivateField(object obj, string name, object value)
{
var t = obj.GetType();
var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); }
Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}");
f.SetValue(obj, value);
}
[Test]
public void Self_buff_fanfare_raises_own_atk_and_life()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
// Minimal opponent/turn wiring (see M2/M3 oracles): opponent refs + active turn flag. The
// self-buff's target resolver (`character=me&target=self`) reads the active player's own
// in-play card, so the turn flag must be set before the fanfare sweeps.
SetPrivateField(player, "_opponentBattlePlayer", enemy);
SetPrivateField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
// Seed leader life so neither leader reads as a 0-life game-over state that silently blocks
// the play (M3 learning); this card deals no damage but the play-legality gate still checks it.
HeadlessEngineEnv.InitLeaderLife(mgr);
// The card's fanfare is gated on `play_count>2` (cards.json skill_condition for 103111050).
// The engine reads this from BattlePlayerBase.GetCurrentTurnPlayCount(); seed it past the
// threshold via the public AddCurrentTrunPlayCount so the powerup actually fires. (Without
// this the card resolves to the board but takes no buff — the delta-vs-base oracle is what
// distinguishes "buff applied" from "fanfare silently gated out".)
player.AddCurrentTrunPlayCount(5);
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.BuffFollowerId);
// Place the self-buff follower in the active player's hand with PP to spare; empty board.
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.BuffFollowerId, 1, isPlayer: true, mgr);
player.HandCardList.Add(card);
player.Pp = 10;
// Pre-state snapshot.
int ppBefore = player.Pp;
int handBefore = player.HandCardList.Count;
int inplayBefore = player.ClassAndInPlayCardList.Count;
int enemyHandBefore = enemy.HandCardList.Count;
int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count;
int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life;
// Resolve the play through the real engine.
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
var ap = new ActionProcessor(pair);
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null),
"ActionProcessor.PlayCard threw on a self-buff fanfare follower");
// Oracle: the own-stat delta is the new M4 dimension; the rest are the §5 follower invariants.
Assert.Multiple(() =>
{
// Primary M4 assertion: the fanfare powerup raised the follower's own stats by exactly
// the buff amounts over its CardCSVData base (1/1 -> 2/2).
Assert.That(card.Atk, Is.EqualTo(cardParam.Atk + HeadlessEngineEnv.BuffAddOffense),
"follower atk != base + fanfare add_offense");
Assert.That(card.Life, Is.EqualTo(cardParam.Life + HeadlessEngineEnv.BuffAddLife),
"follower life != base + fanfare add_life");
// Cost paid.
Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost");
// Follower moved hand -> board.
Assert.That(player.HandCardList, Does.Not.Contain(card), "card still in hand");
Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1");
Assert.That(player.ClassAndInPlayCardList, Contains.Item(card), "card not in play");
Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(inplayBefore + 1), "in-play count not +1");
// Opponent unchanged (the buff targets self, not the opponent).
Assert.That(enemy.HandCardList.Count, Is.EqualTo(enemyHandBefore), "opponent hand changed");
Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore), "opponent board changed");
Assert.That(enemy.ClassAndInPlayCardList[0].Life, Is.EqualTo(enemyLeaderLifeBefore), "opponent leader life changed");
});
}
}
}

View File

@@ -0,0 +1,48 @@
using System;
using NUnit.Framework;
namespace SVSim.BattleEngine.Tests
{
// M2 probe (go/no-go step 1): can BattleManagerBase / the two-player pair be constructed
// HEADLESS at all? This drives the real practice init path
// (`new SingleBattleMgr(StandardBattleMgrContentsCreator)`), which internally builds the
// BattlePlayer + BattleEnemy pair, against the M1 shim — with NO Unity runtime.
//
// The point of this test is diagnostic: if construction throws, the stack trace tells us the
// first shim gap on the *resolution* path (vs the compile path M1 already proved). We assert
// success, but a failure here is the informative outcome we want surfaced.
[TestFixture]
public class ConstructionProbeTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[Test]
public void SingleBattleMgr_constructs_headless()
{
// Mirror the forecast flags the design pins (DP4 / §3): suppress VFX registration and
// collapse wait delays. TestBattleScope already sets ctx.IsForecast=true; this line is a
// belt-and-suspenders write through the ambient setter.
BattleManagerBase.IsForecast = true;
SingleBattleMgr mgr = null;
try
{
mgr = new SingleBattleMgr(new HeadlessContentsCreator());
}
catch (Exception ex)
{
Assert.Fail(
"Headless construction of SingleBattleMgr threw — first shim gap on the " +
"resolution path:\n" + ex);
}
Assert.That(mgr, Is.Not.Null);
Assert.That(mgr.BattlePlayer, Is.Not.Null, "BattlePlayer (self) not created");
Assert.That(mgr.BattleEnemy, Is.Not.Null, "BattleEnemy (opponent) not created");
_scope.Ctx.Mgr = mgr;
}
}
}

View File

@@ -0,0 +1,123 @@
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M9 (the §5 draw oracle): a when_play DRAW spell resolves to correct authoritative state HEADLESS
// via the same IsForecast/IsRecovery + ActionProcessor path M2-M8 proved. The NEW oracle dimension
// is the HAND/DECK DELTA — the deck->hand transfer no prior milestone read: M3/M4/M6/M8 moved
// stats, M2/M5/M7 the board, M3 the leader. The spell's `draw 1` must pull the single seeded deck
// card into the caster's hand (deck -1, that exact card now in hand) while the spell itself pays
// its cost and leaves to the cemetery.
//
// RNG is neutralized structurally (see HeadlessEngineEnv.DrawSpellId): every real draw selects from
// the deck via a `random_count` filter, so the deck is seeded with EXACTLY ONE known card — a
// single-card pool makes `random_count=1` deterministic regardless of the RandomSeed. This rides
// the M5 prefab card-creation path (the deck card is engine-created off the null-view seam) the same
// way the summon-token milestone did.
[TestFixture]
public class DrawSpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
private static void SetPrivateField(object obj, string name, object value)
{
var t = obj.GetType();
var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); }
Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}");
f.SetValue(obj, value);
}
[Test]
public void Draw_spell_moves_the_seeded_deck_card_into_hand()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
// Minimal opponent/turn wiring (see M2-M8 oracles): opponent refs + active turn flag. The
// draw resolves onto the active player's own hand/deck (the skill filter is character=me).
SetPrivateField(player, "_opponentBattlePlayer", enemy);
SetPrivateField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
// Seed leader life: this spell deals no damage, but the play-legality gate still rejects a
// play when a leader reads as a 0-life game-over state (M3 learning).
HeadlessEngineEnv.InitLeaderLife(mgr);
// Seed the card-template prefabs the internal (createNullView:false) creation path clones —
// the draw VFX touches the drawn card's view layer, so keep the M5 prefab surface available.
HeadlessEngineEnv.InitCardTemplates(mgr);
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.DrawSpellId);
// Seed EXACTLY ONE known card on the caster's deck (forces the random_count=1 selection),
// and place the draw spell in hand with PP to spare.
var deckCard = HeadlessEngineEnv.SeedDeck(mgr, HeadlessEngineEnv.DeckSeedCardId, index: 2, isPlayer: true);
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.DrawSpellId, 1, isPlayer: true, mgr);
player.HandCardList.Add(card);
player.Pp = 10;
// Pre-state snapshot.
int ppBefore = player.Pp;
int handBefore = player.HandCardList.Count;
int deckBefore = player.DeckCardList.Count;
int cemeteryBefore = player.CemeteryList.Count;
int playerInplayBefore = player.ClassAndInPlayCardList.Count;
int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count;
// Sanity: the to-be-drawn card starts in the deck, not the hand.
Assert.That(player.DeckCardList, Does.Contain(deckCard), "seeded card not in deck pre-play");
Assert.That(player.HandCardList, Does.Not.Contain(deckCard), "seeded card already in hand pre-play");
// Resolve the play through the real engine.
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
var ap = new ActionProcessor(pair);
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null),
"ActionProcessor.PlayCard threw on a draw spell");
// Oracle: the deck->hand transfer is the new M9 dimension; the rest are the §5 spell-shaped
// invariants proven by M3.
Assert.Multiple(() =>
{
// Primary M9 assertion: the seeded deck card moved into the caster's hand...
Assert.That(player.HandCardList, Does.Contain(deckCard),
"drawn card did not land in hand");
Assert.That(player.HandCardList.Any(c => c.CardId == HeadlessEngineEnv.DeckSeedCardId), Is.True,
"no card with the seeded id is in hand");
// ...and left the deck (deck -1, down to empty here).
Assert.That(player.DeckCardList, Does.Not.Contain(deckCard), "drawn card still in deck");
Assert.That(player.DeckCardList.Count, Is.EqualTo(deckBefore - 1), "deck count not -1");
// The drawn card is the engine's OWN seeded deck object, not a fresh creation.
Assert.That(deckCard.IsInHand, Is.True, "drawn card not marked in-hand");
// The spell itself: pays exactly its cost...
Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost");
// ...leaves the hand (it is consumed, the drawn card replaces it -> net hand count flat)...
Assert.That(player.HandCardList, Does.Not.Contain(card), "spell still in hand");
Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore), "hand count changed (spell -1 + draw +1 should net flat)");
// ...resolves to the cemetery (a spell is not a follower; it never occupies the board).
Assert.That(player.CemeteryList, Does.Contain(card), "spell did not resolve to the cemetery");
Assert.That(player.CemeteryList.Count, Is.EqualTo(cemeteryBefore + 1), "cemetery count not +1");
Assert.That(player.ClassAndInPlayCardList, Does.Not.Contain(card), "spell wrongly placed on the board");
Assert.That(player.ClassAndInPlayCardList, Does.Not.Contain(deckCard), "drawn card wrongly placed on the board");
Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(playerInplayBefore), "player board count changed");
// Opponent untouched (the draw is character=me).
Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore), "opponent board changed");
});
}
}
}

View File

@@ -0,0 +1,142 @@
using System.Reflection;
using NUnit.Framework;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M10 (the first DYNAMIC `{}`-VALUE card — the first deliberate step beyond the four §5-named
// oracle dimensions M2-M9 closed): a when_play spell whose effect MAGNITUDE is COMPUTED by the
// engine from live game state, not carried as a literal. 112134010's sole skill is
// `when_play damage={me.play_count}-1` to units; the `{}` resolves
// (SkillOptionValue.ParseInt -> SkillFilterVariable.Parse -> SkillEnvironmentalPlayCount.Filtering)
// to `GetCurrentTurnPlayCount() - 1`. That GetCurrentTurnPlayCount() is the SAME per-turn counter
// M4 seeded via the public AddCurrentTrunPlayCount to drive a play_count GATE — M10 proves the seam
// also feeds the effect VALUE.
//
// The new oracle dimension over every prior milestone is the ENGINE-COMPUTED VALUE: the asserted
// damage is derived from the engine's OWN live play-count accessor (GetCurrentTurnPlayCount() - 1),
// never a hardcoded literal. Per memory project_battle_relay_nontargeted_effects, a state-derived
// value that the wire could NOT carry (spellboost cost) is exactly what desynced the PvP relay;
// proving the engine resolves a `{}` value headless is the direct validation that the port (not a
// relay) is the necessary path.
//
// Timing note (the M10 first-unknown, RESOLVED empirically by the first RED): the per-play
// auto-increment AddCurrentTrunPlayCount(1) lives in ActionProcessor's OnBeforePlayCard
// (BattlePlayerBase.cs:1400), which is subscribed by SetupActionProcessorEvent — and that is only
// called on the OperateMgr / Prediction / OperationSimulator paths, NOT on the direct
// `new ActionProcessor(pair).PlayCard` (DP4) path this harness uses. So the headless play does NOT
// self-bump the per-turn play count: the skill reads EXACTLY the seeded GetCurrentTurnPlayCount()
// and the damage == seeded - 1. (The first RED expected a +1 that this path never applies; the
// state-derived primary assertion below was right regardless, and the concrete pins were corrected
// to the observed no-bump behavior.)
[TestFixture]
public class DynamicValueSpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
private static void SetPrivateField(object obj, string name, object value)
{
var t = obj.GetType();
var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); }
Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}");
f.SetValue(obj, value);
}
[Test]
public void Dynamic_damage_spell_deals_engine_computed_play_count_value()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
// Minimal opponent/turn wiring (see M2-M6 oracles): opponent refs + active turn flag. The
// spell's target resolver walks player -> opponent -> opponent's in-play units; the
// `{me.play_count}` read keys on the active player's current turn.
SetPrivateField(player, "_opponentBattlePlayer", enemy);
SetPrivateField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
// Seed leader life so neither leader reads as a 0-life game-over state (blocks plays, M3).
HeadlessEngineEnv.InitLeaderLife(mgr);
// Put ONE vanilla follower on the ENEMY board. The spell is `character=both` (AoE over both
// boards' units), but with no player-side units the only matched target is this enemy
// follower; its base life (13) exceeds any seeded play count so it SURVIVES -> clean
// life-delta read (no dependence on death/removal). card_type=unit excludes both leaders.
var target = HeadlessEngineEnv.PutFollowerInPlay(mgr, HeadlessEngineEnv.DynamicDamageTargetFollowerId, 0, isPlayer: false);
// Seed the live game state the `{}` value reads: the active player's current-turn play
// count. This is the M4 seam (AddCurrentTrunPlayCount), here driving the VALUE not a gate.
player.AddCurrentTrunPlayCount(HeadlessEngineEnv.DynamicSeededPlayCount);
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.DynamicDamageSpellId);
// Place the dynamic-value spell in the active player's hand with PP to spare.
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.DynamicDamageSpellId, 1, isPlayer: true, mgr);
player.HandCardList.Add(card);
player.Pp = 10;
// Pre-state snapshot.
int ppBefore = player.Pp;
int handBefore = player.HandCardList.Count;
int playerInplayBefore = player.ClassAndInPlayCardList.Count;
int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count;
int targetLifeBefore = target.Life;
int playerLeaderLifeBefore = player.ClassAndInPlayCardList[0].Life;
int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life;
// Resolve the play through the real engine (auto-targeted AoE -> selectedCards: null).
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
var ap = new ActionProcessor(pair);
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null),
"ActionProcessor.PlayCard threw on a dynamic {}-value damage spell");
// The engine-computed value, derived from the engine's OWN live play-count accessor (the
// direct-ActionProcessor path does not self-bump it, so this reads the seeded value) —
// exactly the value the skill's `{me.play_count}-1` resolved against. NOT a hardcoded
// literal: this is the M10 dimension (effect magnitude computed from state the wire can't
// carry).
int playCountAtResolution = player.GetCurrentTurnPlayCount();
int expectedDamage = playCountAtResolution - 1;
int actualDamage = targetLifeBefore - target.Life;
Assert.Multiple(() =>
{
// PRIMARY M10 assertion: the damage dealt equals the engine-COMPUTED {me.play_count}-1,
// read from live state — proving the engine resolved the `{}` expression, not a literal.
Assert.That(actualDamage, Is.EqualTo(expectedDamage),
"damage dealt did not equal the engine-computed {me.play_count}-1 value");
// Concrete pins (catch a silent state-read failure where play_count would default to 0,
// making damage -1 -> 0): the direct-ActionProcessor path applies no self-play bump, so
// the resolution-time count is exactly the seeded value and the damage is seeded - 1.
Assert.That(playCountAtResolution, Is.EqualTo(HeadlessEngineEnv.DynamicSeededPlayCount),
"play count was not read as the seeded current-turn value");
Assert.That(actualDamage, Is.EqualTo(HeadlessEngineEnv.DynamicSeededPlayCount - 1),
"net damage did not equal seeded play_count - 1 ({me.play_count}-1 mis-resolved)");
// Target survives (life > damage) and stays on the board; both leaders untouched
// (card_type=unit excludes class cards).
Assert.That(target.Life, Is.EqualTo(targetLifeBefore - expectedDamage), "target life delta wrong");
Assert.That(enemy.ClassAndInPlayCardList, Does.Contain(target), "target unexpectedly left the board");
Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore), "enemy board count changed");
Assert.That(player.ClassAndInPlayCardList[0].Life, Is.EqualTo(playerLeaderLifeBefore), "player leader damaged (unit-only AoE hit a leader)");
Assert.That(enemy.ClassAndInPlayCardList[0].Life, Is.EqualTo(enemyLeaderLifeBefore), "enemy leader damaged (unit-only AoE hit a leader)");
// §5 spell-shaped invariants: cost paid, spell leaves hand, does NOT occupy the board.
Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost");
Assert.That(player.HandCardList, Does.Not.Contain(card), "spell still in hand");
Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1");
Assert.That(player.ClassAndInPlayCardList, Does.Not.Contain(card), "spell wrongly placed on the board");
Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(playerInplayBefore), "player board count changed");
});
}
}
}

View File

@@ -0,0 +1,69 @@
using NUnit.Framework;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M13 (hub O1, deterministic): the first headless observation of the EMIT path. Drive the proven M3
// fixed-damage spell (900124030) through mgr.OperateMgr.PlayCard on a NetworkBattleManagerBase-derived
// mgr and confirm the engine reaches its emission path (RealTimeNetworkAgent.OnEmit fires PlayActions)
// without crashing, while the committed state still matches the M3 direct-ActionProcessor oracle.
// Liveness only (E4); structural frame decoding + the RNG rand-list (M14) are deferred.
[TestFixture]
public class EmitPathReadOracleTests : NetworkEmitFixtureBase
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
// The process-global reset (IsForecast=true + clear injected agent) now lives in the shared
// NetworkEmitFixtureBase.ResetNetworkEmitGlobals [TearDown], inherited here — see that file
// for why the leak matters.
[Test]
public void M3_spell_driven_via_OperateMgr_reaches_emit_without_crashing()
{
var (mgr, emitted) = HeadlessEngineEnv.NewNetworkEmitBattle();
_scope.Ctx.Mgr = mgr;
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
int leaderLifeBefore = enemy.Class.Life;
var spell = HeadlessEngineEnv.CreateHeadlessHandCard(
HeadlessEngineEnv.SpellId, index: 1, isPlayer: true, mgr);
player.HandCardList.Add(spell);
int cost = spell.Cost;
player.Pp = 10;
Assert.DoesNotThrow(
() => mgr.OperateMgr.PlayCard(spell, isPlayer: true, selectCards: null),
"OperateMgr.PlayCard threw driving the M3 spell through the emit path");
Assert.Multiple(() =>
{
// Emit reached: OnEmit fired with PlayActions (the O1 liveness signal).
Assert.That(emitted, Does.Contain(NetworkBattleDefine.NetworkBattleURI.PlayActions),
"the engine did not reach a PlayActions emit");
// State intact vs the M3 direct-path oracle.
Assert.That(enemy.Class.Life, Is.EqualTo(leaderLifeBefore - 3), "enemy leader should take 3");
Assert.That(player.Pp, Is.EqualTo(10 - cost), "PP should be paid");
Assert.That(player.HandCardList, Does.Not.Contain(spell), "spell should leave the hand");
Assert.That(player.CemeteryList, Does.Contain(spell), "spell should land in the cemetery");
Assert.That(player.ClassAndInPlayCardList, Does.Not.Contain(spell), "a spell does not occupy the board");
});
// Best-effort (F-E-7): with CurrentMatchingStatus seeded non-Disconnected (NewNetworkEmitBattle),
// the flow reaches stockEmitMessageMgr.StockData(info); read it back. If the stock machinery is
// not drivable headless this milestone, this assertion is DEFERRED to structural validation
// (spec §6) — the OnEmit + no-throw + state checks above are the decisive O1 read on their own.
var agent = Wizard.ToolboxGame.RealTimeNetworkAgent;
var stocked = HeadlessEngineEnv.TryReadStockedEmitData(agent); // returns null if unreachable
if (stocked != null)
Assert.That(stocked, Is.Not.Empty, "the emitted dict should be stocked non-empty");
else
Assert.Inconclusive("payload-presence DEFERRED: stock-sequencer not drivable headless (spec §6)");
}
}
}

View File

@@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M3 (next-hardest deterministic card): a FIXED-DAMAGE SPELL resolves to correct authoritative
// state HEADLESS via the same IsForecast/IsRecovery + ActionProcessor path the M2 vanilla
// follower proved (design §5 / DP4 + M3 resume recipe). The new oracle dimension over M2 is the
// OPPONENT LEADER-LIFE DELTA: the spell's when_play `damage=3` to the enemy leader must reduce
// that leader's Life by exactly 3, with the spell consuming its cost and NOT entering the board.
[TestFixture]
public class FixedDamageSpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
// The spell's sole skill is `damage=3` to the enemy leader (cards.json skill_option for 900124030).
private const int ExpectedLeaderDamage = 3;
private static void SetPrivateField(object obj, string name, object value)
{
var t = obj.GetType();
var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); }
Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}");
f.SetValue(obj, value);
}
[Test]
public void Fixed_damage_spell_reduces_opponent_leader_life()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
// Minimal opponent/turn wiring (see M2 oracle): opponent refs + active turn flag. The
// spell's target resolver walks player -> opponent -> opponent's class card (the leader).
SetPrivateField(player, "_opponentBattlePlayer", enemy);
SetPrivateField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
// Seed leader life (engine's InitializeClassLife subset) so the enemy leader is a live,
// damageable target rather than a 0-life game-over state that blocks the play.
HeadlessEngineEnv.InitLeaderLife(mgr);
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.SpellId);
// Place the spell in the active player's hand with PP to spare; empty board otherwise.
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.SpellId, 1, isPlayer: true, mgr);
player.HandCardList.Add(card);
player.Pp = 10;
// Pre-state snapshot.
int ppBefore = player.Pp;
int handBefore = player.HandCardList.Count;
int playerInplayBefore = player.ClassAndInPlayCardList.Count;
int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count;
int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life;
// Resolve the play through the real engine.
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
var ap = new ActionProcessor(pair);
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null),
"ActionProcessor.PlayCard threw on a fixed-damage spell");
// Oracle: the leader-life delta is the new M3 dimension; the rest are the §5 spell-shaped invariants.
Assert.Multiple(() =>
{
// Primary M3 assertion: opponent leader takes exactly the spell's fixed damage.
Assert.That(enemy.ClassAndInPlayCardList[0].Life,
Is.EqualTo(enemyLeaderLifeBefore - ExpectedLeaderDamage),
"opponent leader life not reduced by the spell's fixed damage");
// Cost paid.
Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost");
// Spell leaves hand.
Assert.That(player.HandCardList, Does.Not.Contain(card), "spell still in hand");
Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1");
// A spell is not a follower: it must NOT occupy the board (resolves to graveyard).
Assert.That(player.ClassAndInPlayCardList, Does.Not.Contain(card), "spell wrongly placed on the board");
Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(playerInplayBefore), "player board count changed");
// Opponent board (leader card only) count unchanged — only its life moved.
Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore), "opponent board count changed");
});
}
}
}

View File

@@ -0,0 +1,40 @@
{"ts":"2026-06-05T16:36:19.3503474Z","direction":"receive","uri":null,"body":{"uri":"InitNetwork","viewerId":999999999,"uuid":"node-stub","try":0,"cat":99,"resultCode":1}}
{"ts":"2026-06-05T16:36:19.3573466Z","direction":"receive","uri":null,"body":{"uri":"Matched","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"bid":"889788596105","playSeq":1,"selfInfo":{"country_code":"KOR","userName":"SVSim1","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":6,"seed":508806643},"oppoInfo":{"country_code":"KOR","userName":"SVSim2","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":7,"seed":508806643,"oppoDeckCount":40},"selfDeck":[{"idx":1,"cardId":100314010},{"idx":2,"cardId":100314020},{"idx":3,"cardId":102324040},{"idx":4,"cardId":101324050},{"idx":5,"cardId":101024010},{"idx":6,"cardId":101314020},{"idx":7,"cardId":101311050},{"idx":8,"cardId":101311010},{"idx":9,"cardId":100314020},{"idx":10,"cardId":101321040},{"idx":11,"cardId":101024010},{"idx":12,"cardId":127011010},{"idx":13,"cardId":100314040},{"idx":14,"cardId":101314020},{"idx":15,"cardId":102331010},{"idx":16,"cardId":102324040},{"idx":17,"cardId":101334040},{"idx":18,"cardId":100321010},{"idx":19,"cardId":101324040},{"idx":20,"cardId":100314030},{"idx":21,"cardId":101324040},{"idx":22,"cardId":101311050},{"idx":23,"cardId":701341011},{"idx":24,"cardId":101324050},{"idx":25,"cardId":100314030},{"idx":26,"cardId":101311010},{"idx":27,"cardId":101321070},{"idx":28,"cardId":101024010},{"idx":29,"cardId":100314040},{"idx":30,"cardId":127011010},{"idx":31,"cardId":127011010},{"idx":32,"cardId":100314010},{"idx":33,"cardId":102334020},{"idx":34,"cardId":101334030},{"idx":35,"cardId":101341010},{"idx":36,"cardId":101321040},{"idx":37,"cardId":101314020},{"idx":38,"cardId":101321070},{"idx":39,"cardId":100321010},{"idx":40,"cardId":101334020}],"resultCode":1}}
{"ts":"2026-06-05T16:36:21.2805258Z","direction":"receive","uri":null,"body":{"uri":"BattleStart","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":2,"turnState":1,"battleType":11,"selfInfo":{"rank":"10","battlePoint":"6270","classId":"3","charaId":"3","cardMasterName":"card_master_node_10015"},"oppoInfo":{"rank":"1","isMasterRank":"0","battlePoint":0,"masterPoint":"0","classId":"1","charaId":"1","cardMasterName":"card_master_node_10015"},"resultCode":1}}
{"ts":"2026-06-05T16:36:21.2820523Z","direction":"receive","uri":null,"body":{"uri":"Deal","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":3,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"resultCode":1}}
{"ts":"2026-06-05T16:36:45.4884447Z","direction":"send","uri":"Swap","body":{"idxList":[3]}}
{"ts":"2026-06-05T16:36:45.4909435Z","direction":"receive","uri":null,"body":{"uri":"Swap","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":4,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":4}],"resultCode":1}}
{"ts":"2026-06-05T16:36:46.8360545Z","direction":"receive","uri":null,"body":{"uri":"Ready","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":5,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":4}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"idxChangeSeed":857671914,"spin":243,"resultCode":1}}
{"ts":"2026-06-05T16:36:46.9530582Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":5,"playSeq":6,"spin":0,"resultCode":1}}
{"ts":"2026-06-05T16:36:49.0622004Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[39],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
{"ts":"2026-06-05T16:36:53.9257769Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":6,"playSeq":7}}
{"ts":"2026-06-05T16:36:53.9473080Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,2,3,39],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":0}}]}}
{"ts":"2026-06-05T16:36:54.4348349Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":7,"playSeq":8,"turnState":0,"resultCode":1}}
{"ts":"2026-06-05T16:36:54.4458360Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"143","key2":"17","key3":"0","key4":"141","key5":"170","key6":"0"}}}
{"ts":"2026-06-05T16:36:54.4643354Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":7,"playSeq":9,"spin":0,"resultCode":1}}
{"ts":"2026-06-05T16:36:54.5198350Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[23,14],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"avarice":1}}],"actionSeq":2}}
{"ts":"2026-06-05T16:36:59.8031059Z","direction":"send","uri":"PlayActions","body":{"playIdx":1,"orderList":[{"move":{"idx":[1],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[2,4,23,14],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"0"}},{"move":{"idx":[8],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"type":30}}
{"ts":"2026-06-05T16:37:02.5213012Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,4,23,14,8],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":0}},{"trigger":{"isSelf":1,"avarice":0}}]}}
{"ts":"2026-06-05T16:37:03.0188508Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"141","key2":"175","key3":"0","key4":"141","key5":"170","key6":"0"},"type":0,"actionSeq":5,"cemetery":[1,0]}}
{"ts":"2026-06-05T16:37:03.1346446Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":12,"playSeq":10,"spin":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:03.1561609Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[37],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
{"ts":"2026-06-05T16:37:07.8849014Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":13,"playSeq":11,"playIdx":37,"type":30,"knownList":[{"idx":37,"cardId":101121020,"to":20,"spellboost":0,"attachTarget":""}]}}
{"ts":"2026-06-05T16:37:08.1357329Z","direction":"send","uri":"Echo","body":{"playIdx":37,"orderList":[{"move":{"idx":[37],"isSelf":0,"from":10,"to":20}},{"playerParam":{"isSelf":0,"buffUnit":1}}],"type":30}}
{"ts":"2026-06-05T16:37:09.1078628Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":14,"playSeq":12}}
{"ts":"2026-06-05T16:37:09.6087702Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":15,"playSeq":13,"turnState":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:11.0449391Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,2,3,39],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":2}}]}}
{"ts":"2026-06-05T16:37:11.4765571Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"141","key2":"175","key3":"0","key4":"143","key5":"170","key6":"101121070"}}}
{"ts":"2026-06-05T16:37:11.4925578Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":15,"playSeq":14,"spin":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:11.5190593Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[24],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":8}}
{"ts":"2026-06-05T16:37:25.1553015Z","direction":"send","uri":"PlayActions","body":{"playIdx":2,"targetList":[{"targetIdx":37,"isSelf":0,"selectSkillIndex":[1]}],"orderList":[{"move":{"idx":[2],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[4,23,14,8,24],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"3"}},{"move":{"idx":[37],"isSelf":0,"from":20,"to":30}},{"move":{"idx":[15],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}},{"trigger":{"isSelf":1,"avarice":1}}],"type":31}}
{"ts":"2026-06-05T16:37:26.1829531Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[4,23,14,8,24,15],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":0}},{"trigger":{"isSelf":1,"avarice":0}}]}}
{"ts":"2026-06-05T16:37:26.6838102Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"142","key2":"334","key3":"0","key4":"145","key5":"170","key6":"0"},"type":0,"actionSeq":11,"cemetery":[2,1]}}
{"ts":"2026-06-05T16:37:28.3338739Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":20,"playSeq":15,"spin":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:28.3556277Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[19],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
{"ts":"2026-06-05T16:37:33.2699751Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":21,"playSeq":16}}
{"ts":"2026-06-05T16:37:33.2873251Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,2,3,39,19],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":0}}]}}
{"ts":"2026-06-05T16:37:33.7738440Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":22,"playSeq":17,"turnState":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:33.7898440Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"142","key2":"334","key3":"0","key4":"147","key5":"265","key6":"0"}}}
{"ts":"2026-06-05T16:37:33.8063464Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":24,"playSeq":18,"spin":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:33.8323438Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[37],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":13}}
{"ts":"2026-06-05T16:37:38.6691412Z","direction":"send","uri":"PlayActions","body":{"playIdx":14,"orderList":[{"move":{"idx":[14],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[4,23,8,24,15,37],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"6"}},{"move":{"idx":[36,18],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"avarice":1}}],"type":30}}

View File

@@ -0,0 +1,38 @@
{"ts":"2026-06-05T16:36:19.3388464Z","direction":"receive","uri":null,"body":{"uri":"InitNetwork","viewerId":999999999,"uuid":"node-stub","try":0,"cat":99,"resultCode":1}}
{"ts":"2026-06-05T16:36:19.3458471Z","direction":"receive","uri":null,"body":{"uri":"Matched","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"bid":"889788596105","playSeq":1,"selfInfo":{"country_code":"KOR","userName":"SVSim2","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":7,"seed":508806643},"oppoInfo":{"country_code":"KOR","userName":"SVSim1","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":6,"seed":508806643,"oppoDeckCount":40},"selfDeck":[{"idx":1,"cardId":100114010},{"idx":2,"cardId":101121080},{"idx":3,"cardId":101114010},{"idx":4,"cardId":113011010},{"idx":5,"cardId":101121020},{"idx":6,"cardId":100111010},{"idx":7,"cardId":102141010},{"idx":8,"cardId":102111060},{"idx":9,"cardId":100111070},{"idx":10,"cardId":113011010},{"idx":11,"cardId":101131050},{"idx":12,"cardId":101121080},{"idx":13,"cardId":100111010},{"idx":14,"cardId":102121010},{"idx":15,"cardId":701141011},{"idx":16,"cardId":100114010},{"idx":17,"cardId":101114050},{"idx":18,"cardId":102131020},{"idx":19,"cardId":102111060},{"idx":20,"cardId":100114010},{"idx":21,"cardId":102121030},{"idx":22,"cardId":102121030},{"idx":23,"cardId":101114050},{"idx":24,"cardId":100111070},{"idx":25,"cardId":100111020},{"idx":26,"cardId":101121110},{"idx":27,"cardId":102131030},{"idx":28,"cardId":113011010},{"idx":29,"cardId":102131010},{"idx":30,"cardId":100111020},{"idx":31,"cardId":101131020},{"idx":32,"cardId":101114050},{"idx":33,"cardId":101121010},{"idx":34,"cardId":101121080},{"idx":35,"cardId":101121110},{"idx":36,"cardId":101114010},{"idx":37,"cardId":101121020},{"idx":38,"cardId":100111020},{"idx":39,"cardId":102121010},{"idx":40,"cardId":101121010}],"resultCode":1}}
{"ts":"2026-06-05T16:36:21.2050506Z","direction":"receive","uri":null,"body":{"uri":"BattleStart","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":2,"turnState":0,"battleType":11,"selfInfo":{"rank":"10","battlePoint":"6270","classId":"1","charaId":"1","cardMasterName":"card_master_node_10015"},"oppoInfo":{"rank":"1","isMasterRank":"0","battlePoint":0,"masterPoint":"0","classId":"3","charaId":"3","cardMasterName":"card_master_node_10015"},"resultCode":1}}
{"ts":"2026-06-05T16:36:21.2065539Z","direction":"receive","uri":null,"body":{"uri":"Deal","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":3,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"resultCode":1}}
{"ts":"2026-06-05T16:36:46.8260552Z","direction":"send","uri":"Swap","body":{"idxList":[]}}
{"ts":"2026-06-05T16:36:46.8285526Z","direction":"receive","uri":null,"body":{"uri":"Swap","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":4,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"resultCode":1}}
{"ts":"2026-06-05T16:36:46.8295526Z","direction":"receive","uri":null,"body":{"uri":"Ready","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":5,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":4}],"idxChangeSeed":224055814,"spin":243,"resultCode":1}}
{"ts":"2026-06-05T16:36:46.9460536Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[39],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":0}}
{"ts":"2026-06-05T16:36:53.9137786Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,2,3,39],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":0}}]}}
{"ts":"2026-06-05T16:36:54.4108350Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"141","key2":"170","key3":"0","key4":"143","key5":"17","key6":"0"},"type":0,"actionSeq":2,"cemetery":[0,0]}}
{"ts":"2026-06-05T16:36:54.5258347Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":8,"playSeq":6,"spin":0,"resultCode":1}}
{"ts":"2026-06-05T16:36:54.5523350Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[23,14],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"avarice":1}}]}}
{"ts":"2026-06-05T16:36:59.8136078Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":9,"playSeq":7,"playIdx":1,"type":30,"knownList":[{"idx":1,"cardId":100314010,"to":30,"spellboost":0,"attachTarget":""}]}}
{"ts":"2026-06-05T16:37:00.0026151Z","direction":"send","uri":"Echo","body":{"playIdx":1,"orderList":[{"move":{"idx":[1],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[2,4,23,14],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"0"}},{"move":{"idx":[8],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}],"type":30}}
{"ts":"2026-06-05T16:37:02.5313002Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":10,"playSeq":8}}
{"ts":"2026-06-05T16:37:02.5503289Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,4,23,14,8],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":0}},{"trigger":{"isSelf":0,"avarice":0}}]}}
{"ts":"2026-06-05T16:37:03.0339655Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":11,"playSeq":9,"turnState":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:03.0510647Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"141","key2":"170","key3":"0","key4":"141","key5":"175","key6":"0"}}}
{"ts":"2026-06-05T16:37:03.0670774Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":11,"playSeq":10,"spin":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:03.1321443Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[37],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":5}}
{"ts":"2026-06-05T16:37:07.8809043Z","direction":"send","uri":"PlayActions","body":{"playIdx":37,"orderList":[{"move":{"idx":[37],"isSelf":1,"from":10,"to":20}},{"playerParam":{"isSelf":1,"buffUnit":1}}],"type":30}}
{"ts":"2026-06-05T16:37:09.0943648Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,2,3,39],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":2}}]}}
{"ts":"2026-06-05T16:37:09.5927718Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"143","key2":"170","key3":"101121070","key4":"141","key5":"175","key6":"0"},"type":0,"actionSeq":8,"cemetery":[0,1]}}
{"ts":"2026-06-05T16:37:11.5305571Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":16,"playSeq":11,"spin":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:11.5519635Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[24],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
{"ts":"2026-06-05T16:37:25.1769841Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":19,"playSeq":12,"playIdx":2,"type":31,"knownList":[{"idx":2,"cardId":100314020,"to":30,"spellboost":1,"attachTarget":""}],"oppoTargetList":[{"targetIdx":37,"isSelf":0}]}}
{"ts":"2026-06-05T16:37:25.3675799Z","direction":"send","uri":"Echo","body":{"playIdx":2,"orderList":[{"move":{"idx":[2],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[4,23,14,8,24],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"3"}},{"move":{"idx":[37],"isSelf":1,"from":20,"to":30}},{"move":{"idx":[15],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}},{"trigger":{"isSelf":0,"avarice":1}}],"type":31}}
{"ts":"2026-06-05T16:37:26.1899527Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":20,"playSeq":13}}
{"ts":"2026-06-05T16:37:26.6913132Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":21,"playSeq":14,"turnState":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:28.1438230Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[4,23,14,8,24,15],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":0}},{"trigger":{"isSelf":0,"avarice":0}}]}}
{"ts":"2026-06-05T16:37:28.2597994Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"145","key2":"170","key3":"0","key4":"142","key5":"334","key6":"0"}}}
{"ts":"2026-06-05T16:37:28.2755229Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"889788596105","pubSeq":19,"playSeq":15,"spin":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:28.3213347Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[19],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":11}}
{"ts":"2026-06-05T16:37:33.2604742Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,2,3,39,19],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":0}}]}}
{"ts":"2026-06-05T16:37:33.7603450Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"147","key2":"265","key3":"0","key4":"142","key5":"334","key6":"0"},"type":0,"actionSeq":13,"cemetery":[1,2]}}
{"ts":"2026-06-05T16:37:33.8438435Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":25,"playSeq":16,"spin":0,"resultCode":1}}
{"ts":"2026-06-05T16:37:33.8648584Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[37],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
{"ts":"2026-06-05T16:37:38.6786420Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"889788596105","pubSeq":26,"playSeq":17,"playIdx":14,"type":30,"knownList":[{"idx":14,"cardId":101314020,"to":30,"spellboost":2,"attachTarget":""}]}}

View File

@@ -0,0 +1,109 @@
{"ts":"2026-06-07T12:05:10.0824442Z","direction":"receive","uri":null,"body":{"uri":"InitNetwork","viewerId":999999999,"uuid":"node-stub","try":0,"cat":99,"resultCode":1}}
{"ts":"2026-06-07T12:05:10.1134456Z","direction":"receive","uri":null,"body":{"uri":"Matched","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"bid":"907324319325","playSeq":1,"selfInfo":{"country_code":"KOR","userName":"SVSim1","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":6,"seed":742186477},"oppoInfo":{"country_code":"KOR","userName":"SVSim2","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":7,"seed":742186477,"oppoDeckCount":40},"selfDeck":[{"idx":1,"cardId":101324040},{"idx":2,"cardId":101321070},{"idx":3,"cardId":101321040},{"idx":4,"cardId":101324050},{"idx":5,"cardId":101334030},{"idx":6,"cardId":102334020},{"idx":7,"cardId":101024010},{"idx":8,"cardId":102331010},{"idx":9,"cardId":101324040},{"idx":10,"cardId":101314020},{"idx":11,"cardId":127011010},{"idx":12,"cardId":100314020},{"idx":13,"cardId":101024010},{"idx":14,"cardId":701341011},{"idx":15,"cardId":101311010},{"idx":16,"cardId":101311050},{"idx":17,"cardId":102324040},{"idx":18,"cardId":101341010},{"idx":19,"cardId":127011010},{"idx":20,"cardId":101311010},{"idx":21,"cardId":101314020},{"idx":22,"cardId":100321010},{"idx":23,"cardId":101321070},{"idx":24,"cardId":100314030},{"idx":25,"cardId":101314020},{"idx":26,"cardId":101311050},{"idx":27,"cardId":101024010},{"idx":28,"cardId":100314010},{"idx":29,"cardId":127011010},{"idx":30,"cardId":100314040},{"idx":31,"cardId":100321010},{"idx":32,"cardId":101334020},{"idx":33,"cardId":100314030},{"idx":34,"cardId":100314040},{"idx":35,"cardId":101321040},{"idx":36,"cardId":102324040},{"idx":37,"cardId":100314020},{"idx":38,"cardId":101334040},{"idx":39,"cardId":100314010},{"idx":40,"cardId":101324050}],"resultCode":1}}
{"ts":"2026-06-07T12:05:13.3684415Z","direction":"receive","uri":null,"body":{"uri":"BattleStart","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":2,"turnState":0,"battleType":11,"selfInfo":{"rank":"10","battlePoint":"6270","classId":"3","charaId":"3","cardMasterName":"card_master_node_10015"},"oppoInfo":{"rank":"1","isMasterRank":"0","battlePoint":0,"masterPoint":"0","classId":"1","charaId":"1","cardMasterName":"card_master_node_10015"},"resultCode":1}}
{"ts":"2026-06-07T12:05:13.3699431Z","direction":"receive","uri":null,"body":{"uri":"Deal","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":3,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"resultCode":1}}
{"ts":"2026-06-07T12:05:34.8570706Z","direction":"send","uri":"Swap","body":{"idxList":[2,3]}}
{"ts":"2026-06-07T12:05:34.8895711Z","direction":"receive","uri":null,"body":{"uri":"Swap","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":4,"self":[{"pos":0,"idx":1},{"pos":1,"idx":4},{"pos":2,"idx":5}],"resultCode":1}}
{"ts":"2026-06-07T12:05:34.8905684Z","direction":"receive","uri":null,"body":{"uri":"Ready","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":5,"self":[{"pos":0,"idx":1},{"pos":1,"idx":4},{"pos":2,"idx":5}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"idxChangeSeed":1430655717,"spin":243,"resultCode":1}}
{"ts":"2026-06-07T12:05:36.6990699Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[8],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":0}}
{"ts":"2026-06-07T12:05:42.2485694Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,4,5,8],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":0}}]}}
{"ts":"2026-06-07T12:05:42.7450678Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"141","key2":"56","key3":"0","key4":"143","key5":"14","key6":"0"},"type":0,"actionSeq":2,"cemetery":[0,0]}}
{"ts":"2026-06-07T12:05:42.8775704Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":8,"playSeq":6,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:05:42.9050694Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[10,16],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"avarice":1}}]}}
{"ts":"2026-06-07T12:05:46.4670675Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":9,"playSeq":7}}
{"ts":"2026-06-07T12:05:46.4855683Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,2,3,10,16],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":0}},{"trigger":{"isSelf":0,"avarice":0}}]}}
{"ts":"2026-06-07T12:05:46.9690709Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":10,"playSeq":8,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:05:46.9860711Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"141","key2":"56","key3":"0","key4":"142","key5":"134","key6":"0"}}}
{"ts":"2026-06-07T12:05:47.0020697Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":10,"playSeq":9,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:05:47.4990684Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[29],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":4}}
{"ts":"2026-06-07T12:05:54.6460692Z","direction":"send","uri":"PlayActions","body":{"playIdx":8,"orderList":[{"move":{"idx":[8],"isSelf":1,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:05:55.7140680Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,4,5,29],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":2}}]}}
{"ts":"2026-06-07T12:05:56.2210693Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"143","key2":"140","key3":"102331036","key4":"142","key5":"134","key6":"0"},"type":0,"actionSeq":7,"cemetery":[0,0]}}
{"ts":"2026-06-07T12:05:57.0875698Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":15,"playSeq":10,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:05:57.1090694Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[15],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
{"ts":"2026-06-07T12:06:12.6924224Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":16,"playSeq":11,"playIdx":1,"type":30,"knownList":[{"idx":1,"cardId":102131030,"to":20,"spellboost":0,"attachTarget":"","cost":2,"clan":1,"tribe":"0"}]}}
{"ts":"2026-06-07T12:06:12.9394251Z","direction":"send","uri":"Echo","body":{"playIdx":1,"orderList":[{"move":{"idx":[1],"isSelf":0,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:06:16.5024225Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":17,"playSeq":12}}
{"ts":"2026-06-07T12:06:16.5194264Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,10,16,15],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":2}}]}}
{"ts":"2026-06-07T12:06:16.9874227Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":18,"playSeq":13,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:17.0039250Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"143","key2":"140","key3":"102331036","key4":"144","key5":"177","key6":"102131049"}}}
{"ts":"2026-06-07T12:06:17.0209229Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":18,"playSeq":14,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:17.0494250Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[3],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":10}}
{"ts":"2026-06-07T12:06:28.8094232Z","direction":"send","uri":"PlayActions","body":{"playIdx":3,"orderList":[{"move":{"idx":[3],"isSelf":1,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:06:29.8539237Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,4,5,29],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":2}}]}}
{"ts":"2026-06-07T12:06:30.3519249Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"145","key2":"140","key3":"203652104","key4":"144","key5":"177","key6":"102131049"},"type":0,"actionSeq":13,"cemetery":[0,0]}}
{"ts":"2026-06-07T12:06:31.2029243Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":23,"playSeq":15,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:31.2239242Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[24],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
{"ts":"2026-06-07T12:06:36.0499227Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":25,"playSeq":16,"playIdx":1,"type":10,"knownList":[{"idx":1,"cardId":102131030,"to":30,"spellboost":0,"attachTarget":"","cost":2,"clan":1,"tribe":"0"}],"oppoTargetList":[{"targetIdx":8,"isSelf":0}]}}
{"ts":"2026-06-07T12:06:36.0879224Z","direction":"send","uri":"Echo","body":{"playIdx":1,"orderList":[{"move":{"idx":[1],"isSelf":0,"from":20,"to":30}},{"move":{"idx":[8],"isSelf":1,"from":20,"to":30}}],"type":10}}
{"ts":"2026-06-07T12:06:36.7079231Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":26,"playSeq":17}}
{"ts":"2026-06-07T12:06:37.1924235Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":27,"playSeq":18,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:38.0604227Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,10,16,15,24],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":0}}]}}
{"ts":"2026-06-07T12:06:38.1769227Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"147","key2":"140","key3":"101321058","key4":"148","key5":"321","key6":"0"}}}
{"ts":"2026-06-07T12:06:38.1919253Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":26,"playSeq":19,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:38.2194225Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":2}},{"move":{"idx":[19],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":16}}
{"ts":"2026-06-07T12:06:46.5499241Z","direction":"send","uri":"PlayActions","body":{"playIdx":29,"keyAction":[{"type":1,"cardId":127011010,"selectCard":{"cardId":[121011010],"open":0}}],"orderList":[{"move":{"idx":[29],"isSelf":1,"from":10,"to":20}},{"add":{"idx":[41],"isSelf":1,"card":{"cardId":121011010},"isChoice":"1"}},{"move":{"idx":[41],"isSelf":1,"from":50,"to":10}}],"type":30}}
{"ts":"2026-06-07T12:06:50.3119230Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,4,5,19,41],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":2}}]}}
{"ts":"2026-06-07T12:06:50.8109234Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"149","key2":"305","key3":"228332150","key4":"148","key5":"321","key6":"0"},"type":0,"actionSeq":19,"cemetery":[1,1]}}
{"ts":"2026-06-07T12:06:50.9109252Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":32,"playSeq":20,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:50.9319252Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[11],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
{"ts":"2026-06-07T12:06:55.3344248Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":33,"playSeq":21,"playIdx":10,"type":30,"knownList":[{"idx":10,"cardId":101121080,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
{"ts":"2026-06-07T12:06:55.5239284Z","direction":"send","uri":"Echo","body":{"playIdx":10,"orderList":[{"move":{"idx":[10],"isSelf":0,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:06:56.0979233Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":34,"playSeq":22}}
{"ts":"2026-06-07T12:06:56.5964232Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":35,"playSeq":23,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:57.4474248Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,16,15,24,11],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":3}}]}}
{"ts":"2026-06-07T12:06:57.5634280Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"149","key2":"305","key3":"228332150","key4":"150","key5":"302","key6":"101121116"}}}
{"ts":"2026-06-07T12:06:57.5794253Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":36,"playSeq":24,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:57.6139259Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[39],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":22}}
{"ts":"2026-06-07T12:07:02.6699249Z","direction":"send","uri":"PlayActions","body":{"playIdx":39,"orderList":[{"move":{"idx":[39],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[1,4,5,19,41],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"7"}},{"move":{"idx":[17],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}},{"trigger":{"isSelf":1,"avarice":1}}],"type":30}}
{"ts":"2026-06-07T12:07:10.2104225Z","direction":"send","uri":"PlayActions","body":{"playIdx":41,"orderList":[{"move":{"idx":[41],"isSelf":1,"from":10,"to":20}},{"move":{"idx":[6],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"type":30}}
{"ts":"2026-06-07T12:07:17.7444250Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,4,5,19,17,6],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":3}},{"trigger":{"isSelf":1,"avarice":0}}]}}
{"ts":"2026-06-07T12:07:18.2599231Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"147","key2":"221","key3":"349343345","key4":"150","key5":"302","key6":"101121116"},"type":0,"actionSeq":26,"cemetery":[2,1]}}
{"ts":"2026-06-07T12:07:18.3594228Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":41,"playSeq":25,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:07:18.3874231Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[6],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
{"ts":"2026-06-07T12:07:22.0834250Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":42,"playSeq":26,"playIdx":6,"type":30,"knownList":[{"idx":6,"cardId":113011010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"uList":[{"idxList":[34],"from":0,"to":10,"isSelf":1,"skill":"6|19|0"}]}}
{"ts":"2026-06-07T12:07:22.2814232Z","direction":"send","uri":"Echo","body":{"playIdx":6,"orderList":[{"move":{"idx":[6],"isSelf":0,"from":10,"to":20}},{"target":{"isSelf":0,"group":["g1"],"conditions":[{"state":0,"tribe":"eq7"}],"rand":[[0.739030951046865]]}},{"move":{"idx":"g1","isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}},{"trigger":{"isSelf":0,"avarice":1}}],"type":30}}
{"ts":"2026-06-07T12:07:25.6384231Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":43,"playSeq":27}}
{"ts":"2026-06-07T12:07:25.6554259Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,16,15,24,11,34],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":3}},{"trigger":{"isSelf":0,"avarice":0}}]}}
{"ts":"2026-06-07T12:07:26.1384241Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":44,"playSeq":28,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:07:26.1544243Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"147","key2":"221","key3":"349343345","key4":"149","key5":"540","key6":"214132162"}}}
{"ts":"2026-06-07T12:07:26.1709251Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":45,"playSeq":29,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:07:26.2184224Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[32],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":29}}
{"ts":"2026-06-07T12:07:34.2019228Z","direction":"send","uri":"PlayActions","body":{"playIdx":1,"targetList":[{"targetIdx":6,"isSelf":1,"selectSkillIndex":[1],"skillIndex":[1]}],"orderList":[{"move":{"idx":[1],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[4,5,19,17,6,32],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"20"}},{"alter":{"idx":[6],"isSelf":1,"type":"add","spellboost":"a2","attachTarget":"21"}},{"move":{"idx":[23],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}},{"trigger":{"isSelf":1,"avarice":1}}],"type":31}}
{"ts":"2026-06-07T12:07:41.2306722Z","direction":"send","uri":"PlayActions","body":{"playIdx":17,"orderList":[{"move":{"idx":[17],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[4,5,19,6,32,23],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"23"}},{"add":{"idx":[42],"isSelf":1,"card":{"cardId":900311050}}},{"move":{"idx":[42],"isSelf":1,"from":50,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:07:46.6846799Z","direction":"send","uri":"PlayActions","body":{"playIdx":41,"targetList":[{"targetIdx":10,"isSelf":0}],"type":10}}
{"ts":"2026-06-07T12:07:48.2356829Z","direction":"send","uri":"PlayActions","body":{"playIdx":29,"targetList":[{"targetIdx":10,"isSelf":0}],"orderList":[{"move":{"idx":[29],"isSelf":1,"from":20,"to":30}},{"move":{"idx":[10],"isSelf":0,"from":20,"to":30}}],"type":10}}
{"ts":"2026-06-07T12:07:49.9904200Z","direction":"send","uri":"PlayActions","body":{"playIdx":3,"targetList":[{"targetIdx":6,"isSelf":0}],"orderList":[{"move":{"idx":[3],"isSelf":1,"from":20,"to":30}},{"move":{"idx":[6],"isSelf":0,"from":20,"to":30}}],"type":10}}
{"ts":"2026-06-07T12:07:51.8734061Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[4,5,19,6,32,23],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":5}},{"trigger":{"isSelf":1,"avarice":0}}]}}
{"ts":"2026-06-07T12:07:52.3726572Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"154","key2":"393","key3":"1021322270","key4":"153","key5":"540","key6":"0"},"type":0,"actionSeq":36,"cemetery":[6,3]}}
{"ts":"2026-06-07T12:07:52.4729369Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":53,"playSeq":30,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:07:52.4946960Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":6}},{"move":{"idx":[18],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
{"ts":"2026-06-07T12:07:57.1776003Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":54,"playSeq":31,"playIdx":34,"type":30,"knownList":[{"idx":34,"cardId":113011010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"uList":[{"idxList":[5],"from":0,"to":10,"isSelf":1,"skill":"34|28|0"}]}}
{"ts":"2026-06-07T12:07:57.2503917Z","direction":"send","uri":"Echo","body":{"playIdx":34,"orderList":[{"move":{"idx":[34],"isSelf":0,"from":10,"to":20}},{"target":{"isSelf":0,"group":["g1"],"conditions":[{"state":0,"tribe":"eq7"}],"rand":[[0.668529128501438]]}},{"move":{"idx":"g1","isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}},{"trigger":{"isSelf":0,"avarice":1}}],"type":30}}
{"ts":"2026-06-07T12:07:58.2623261Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":55,"playSeq":32,"playIdx":18,"type":30,"knownList":[{"idx":18,"cardId":100111010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
{"ts":"2026-06-07T12:08:00.2645722Z","direction":"send","uri":"Echo","body":{"playIdx":18,"orderList":[{"move":{"idx":[18],"isSelf":0,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:08:02.7695981Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":56,"playSeq":33,"playIdx":5,"type":30,"knownList":[{"idx":5,"cardId":113011010,"to":20,"spellboost":0,"attachTarget":"","cost":2,"clan":0,"tribe":"7"}]}}
{"ts":"2026-06-07T12:08:02.8451199Z","direction":"send","uri":"Echo","body":{"playIdx":5,"orderList":[{"move":{"idx":[5],"isSelf":0,"from":10,"to":20}},{"scan":{"idx":[4,7,8,9,12,13,14,17,19,20,21,22,23,25,26,27,28,29,30,31,32,33,35,36,37,38,39,40],"conditions":[{"tribe":"7"}]}}],"type":30}}
{"ts":"2026-06-07T12:08:05.7442862Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":57,"playSeq":34}}
{"ts":"2026-06-07T12:08:05.7667846Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,16,15,24,11],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":1}},{"move":{"idx":[42],"isSelf":1,"from":20,"to":30,"hasGuard":[42]}},{"trigger":{"isSelf":0,"avarice":0}}]}}
{"ts":"2026-06-07T12:08:06.2448192Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":58,"playSeq":35,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:08:06.2608181Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"156","key2":"393","key3":"121011060","key4":"152","key5":"302","key6":"326133205"}}}
{"ts":"2026-06-07T12:08:06.2778185Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":64,"playSeq":36,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:08:06.3228189Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[38],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":41}}
{"ts":"2026-06-07T12:08:17.8343721Z","direction":"send","uri":"PlayActions","body":{"playIdx":19,"keyAction":[{"type":1,"cardId":127011010,"selectCard":{"cardId":[120011010],"open":0}}],"orderList":[{"move":{"idx":[19],"isSelf":1,"from":10,"to":20}},{"add":{"idx":[43],"isSelf":1,"card":{"cardId":120011010},"isChoice":"1"}},{"move":{"idx":[43],"isSelf":1,"from":50,"to":10}}],"type":30}}
{"ts":"2026-06-07T12:08:21.3291075Z","direction":"send","uri":"PlayActions","body":{"playIdx":4,"targetList":[{"targetIdx":5,"isSelf":0,"selectSkillIndex":[1]}],"orderList":[{"move":{"idx":[4],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[5,6,32,23,38,43],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"33"}},{"metamorphose":{"idx":[5],"isSelf":0,"after":{"cardId":900311020}}}],"type":31}}
{"ts":"2026-06-07T12:08:25.9578557Z","direction":"send","uri":"PlayActions","body":{"playIdx":5,"targetList":[{"targetIdx":34,"isSelf":0,"selectSkillIndex":[1]}],"orderList":[{"move":{"idx":[5],"isSelf":1,"from":10,"to":30}},{"alter":{"idx":[6,32,23,38,43],"isSelf":1,"type":"add","spellboost":"a1","attachTarget":"36"}},{"move":{"idx":[34],"isSelf":0,"from":20,"to":30}},{"add":{"idx":[44],"isSelf":1,"card":{"cardId":900334010}}},{"move":{"idx":[44],"isSelf":1,"from":50,"to":10}}],"type":31}}
{"ts":"2026-06-07T12:08:29.5860517Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[6,32,23,38,43,44],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":3}}]}}
{"ts":"2026-06-07T12:08:30.0854894Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"162","key2":"770","key3":"248022140","key4":"154","key5":"302","key6":"1000422107"},"type":0,"actionSeq":46,"cemetery":[9,4]}}
{"ts":"2026-06-07T12:08:30.1853353Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":65,"playSeq":37,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:08:30.2078357Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[35],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
{"ts":"2026-06-07T12:08:37.7255447Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":66,"playSeq":38,"playIdx":15,"type":30,"knownList":[{"idx":15,"cardId":101121110,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
{"ts":"2026-06-07T12:08:37.9275599Z","direction":"send","uri":"Echo","body":{"playIdx":15,"orderList":[{"move":{"idx":[15],"isSelf":0,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:08:38.5997627Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":67,"playSeq":39}}
{"ts":"2026-06-07T12:08:39.0994174Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":68,"playSeq":40,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:08:39.8688009Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[2,3,16,24,11,35],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":5}}]}}
{"ts":"2026-06-07T12:08:39.9995393Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"162","key2":"770","key3":"248022140","key4":"156","key5":"417","key6":"1101543355"}}}
{"ts":"2026-06-07T12:08:40.0160656Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":80,"playSeq":41,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:08:40.0427529Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[20],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":49}}
{"ts":"2026-06-07T12:08:44.0590977Z","direction":"send","uri":"PlayActions","body":{"playIdx":20,"orderList":[{"move":{"idx":[20],"isSelf":1,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:08:49.2798814Z","direction":"send","uri":"PlayActions","body":{"playIdx":23,"orderList":[{"move":{"idx":[23],"isSelf":1,"from":10,"to":20}},{"playerParam":{"isSelf":1,"buffUnit":1}}],"type":30}}

View File

@@ -0,0 +1,118 @@
{"ts":"2026-06-07T12:05:10.0764449Z","direction":"receive","uri":null,"body":{"uri":"InitNetwork","viewerId":999999999,"uuid":"node-stub","try":0,"cat":99,"resultCode":1}}
{"ts":"2026-06-07T12:05:10.1264431Z","direction":"receive","uri":null,"body":{"uri":"Matched","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"bid":"907324319325","playSeq":1,"selfInfo":{"country_code":"KOR","userName":"SVSim2","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":7,"seed":742186477},"oppoInfo":{"country_code":"KOR","userName":"SVSim1","sleeveId":"3000011","emblemId":"100000000","degreeId":"300003","fieldId":43,"isOfficial":0,"oppoId":6,"seed":742186477,"oppoDeckCount":40},"selfDeck":[{"idx":1,"cardId":102131030},{"idx":2,"cardId":101121080},{"idx":3,"cardId":101131050},{"idx":4,"cardId":101114010},{"idx":5,"cardId":113011010},{"idx":6,"cardId":113011010},{"idx":7,"cardId":101121020},{"idx":8,"cardId":101121010},{"idx":9,"cardId":102141010},{"idx":10,"cardId":101121080},{"idx":11,"cardId":101114010},{"idx":12,"cardId":102111060},{"idx":13,"cardId":102131020},{"idx":14,"cardId":102131010},{"idx":15,"cardId":101121110},{"idx":16,"cardId":101121110},{"idx":17,"cardId":100111020},{"idx":18,"cardId":100111010},{"idx":19,"cardId":102121030},{"idx":20,"cardId":100111020},{"idx":21,"cardId":101121080},{"idx":22,"cardId":101121020},{"idx":23,"cardId":100111070},{"idx":24,"cardId":102111060},{"idx":25,"cardId":101131020},{"idx":26,"cardId":101114050},{"idx":27,"cardId":101114050},{"idx":28,"cardId":101121010},{"idx":29,"cardId":701141011},{"idx":30,"cardId":102121010},{"idx":31,"cardId":100111010},{"idx":32,"cardId":100114010},{"idx":33,"cardId":101114050},{"idx":34,"cardId":113011010},{"idx":35,"cardId":100114010},{"idx":36,"cardId":100111020},{"idx":37,"cardId":102121030},{"idx":38,"cardId":102121010},{"idx":39,"cardId":100114010},{"idx":40,"cardId":100111070}],"resultCode":1}}
{"ts":"2026-06-07T12:05:13.3624432Z","direction":"receive","uri":null,"body":{"uri":"BattleStart","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":2,"turnState":1,"battleType":11,"selfInfo":{"rank":"10","battlePoint":"6270","classId":"1","charaId":"1","cardMasterName":"card_master_node_10015"},"oppoInfo":{"rank":"1","isMasterRank":"0","battlePoint":0,"masterPoint":"0","classId":"3","charaId":"3","cardMasterName":"card_master_node_10015"},"resultCode":1}}
{"ts":"2026-06-07T12:05:13.3644442Z","direction":"receive","uri":null,"body":{"uri":"Deal","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":3,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"resultCode":1}}
{"ts":"2026-06-07T12:05:29.7550686Z","direction":"send","uri":"Swap","body":{"idxList":[]}}
{"ts":"2026-06-07T12:05:29.7695695Z","direction":"receive","uri":null,"body":{"uri":"Swap","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":4,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"resultCode":1}}
{"ts":"2026-06-07T12:05:34.8895711Z","direction":"receive","uri":null,"body":{"uri":"Ready","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"playSeq":5,"self":[{"pos":0,"idx":1},{"pos":1,"idx":2},{"pos":2,"idx":3}],"oppo":[{"pos":0,"idx":1},{"pos":1,"idx":4},{"pos":2,"idx":5}],"idxChangeSeed":661650374,"spin":243,"resultCode":1}}
{"ts":"2026-06-07T12:05:36.7840686Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":5,"playSeq":6,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:05:37.9140709Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[8],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
{"ts":"2026-06-07T12:05:42.3100693Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":6,"playSeq":7}}
{"ts":"2026-06-07T12:05:42.3835692Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,4,5,8],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":0}}]}}
{"ts":"2026-06-07T12:05:42.7575705Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":7,"playSeq":8,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:05:42.7750675Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"143","key2":"14","key3":"0","key4":"141","key5":"56","key6":"0"}}}
{"ts":"2026-06-07T12:05:42.7905712Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":7,"playSeq":9,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:05:42.8590737Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[10,16],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"avarice":1}}],"actionSeq":2}}
{"ts":"2026-06-07T12:05:46.4565675Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[1,2,3,10,16],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":0}},{"trigger":{"isSelf":1,"avarice":0}}]}}
{"ts":"2026-06-07T12:05:46.9540693Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"142","key2":"134","key3":"0","key4":"141","key5":"56","key6":"0"},"type":0,"actionSeq":4,"cemetery":[0,0]}}
{"ts":"2026-06-07T12:05:47.5195696Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":11,"playSeq":10,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:05:47.5415707Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[29],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
{"ts":"2026-06-07T12:05:54.7275709Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":12,"playSeq":11,"playIdx":8,"type":30,"knownList":[{"idx":8,"cardId":102331010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
{"ts":"2026-06-07T12:05:54.9510692Z","direction":"send","uri":"Echo","body":{"playIdx":8,"orderList":[{"move":{"idx":[8],"isSelf":0,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:05:55.7230693Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":13,"playSeq":12}}
{"ts":"2026-06-07T12:05:56.2255669Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":14,"playSeq":13,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:05:56.9100687Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,4,5,29],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":2}}]}}
{"ts":"2026-06-07T12:05:57.0275696Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"142","key2":"134","key3":"0","key4":"143","key5":"140","key6":"102331036"}}}
{"ts":"2026-06-07T12:05:57.0415684Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":14,"playSeq":14,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:05:57.0740682Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[15],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":7}}
{"ts":"2026-06-07T12:06:12.6129250Z","direction":"send","uri":"PlayActions","body":{"playIdx":1,"orderList":[{"move":{"idx":[1],"isSelf":1,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:06:16.4794226Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,10,16,15],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":2}}]}}
{"ts":"2026-06-07T12:06:16.9789227Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"144","key2":"177","key3":"102131049","key4":"143","key5":"140","key6":"102331036"},"type":0,"actionSeq":10,"cemetery":[0,0]}}
{"ts":"2026-06-07T12:06:17.0619236Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":19,"playSeq":15,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:17.0839228Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[3],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
{"ts":"2026-06-07T12:06:28.8204242Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":20,"playSeq":16,"playIdx":3,"type":30,"knownList":[{"idx":3,"cardId":101321040,"to":20,"spellboost":0,"attachTarget":"","cost":2,"clan":3,"tribe":"0"}]}}
{"ts":"2026-06-07T12:06:29.0409223Z","direction":"send","uri":"Echo","body":{"playIdx":3,"orderList":[{"move":{"idx":[3],"isSelf":0,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:06:29.8804238Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":21,"playSeq":17}}
{"ts":"2026-06-07T12:06:30.3639243Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":22,"playSeq":18,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:30.9664239Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,4,5,29],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":2}}]}}
{"ts":"2026-06-07T12:06:31.1154246Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"144","key2":"177","key3":"102131049","key4":"145","key5":"140","key6":"203652104"}}}
{"ts":"2026-06-07T12:06:31.1309231Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":22,"playSeq":19,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:31.1914245Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[24],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":13}}
{"ts":"2026-06-07T12:06:36.0239226Z","direction":"send","uri":"PlayActions","body":{"playIdx":1,"targetList":[{"targetIdx":8,"isSelf":0}],"orderList":[{"move":{"idx":[1],"isSelf":1,"from":20,"to":30}},{"move":{"idx":[8],"isSelf":0,"from":20,"to":30}}],"type":10}}
{"ts":"2026-06-07T12:06:36.6854243Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,10,16,15,24],"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":0}}]}}
{"ts":"2026-06-07T12:06:37.1859231Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"148","key2":"321","key3":"0","key4":"147","key5":"140","key6":"101321058"},"type":0,"actionSeq":16,"cemetery":[1,1]}}
{"ts":"2026-06-07T12:06:38.2359235Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":27,"playSeq":20,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:38.2569229Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":2}},{"move":{"idx":[19],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
{"ts":"2026-06-07T12:06:46.5794252Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":30,"playSeq":21,"playIdx":29,"type":30,"knownList":[{"idx":29,"cardId":127011010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"keyAction":[{"type":1,"cardId":127011010}]}}
{"ts":"2026-06-07T12:06:47.0374244Z","direction":"send","uri":"Echo","body":{"playIdx":29,"orderList":[{"move":{"idx":[29],"isSelf":0,"from":10,"to":20}},{"add":{"idx":[41],"isSelf":0,"card":{"candidates":[121011010,120011010]},"isChoice":"1"}},{"move":{"idx":[41],"isSelf":0,"from":50,"to":10}}],"type":30}}
{"ts":"2026-06-07T12:06:50.3279267Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":31,"playSeq":22}}
{"ts":"2026-06-07T12:06:50.3444241Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,4,5,19,41],"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":2}}]}}
{"ts":"2026-06-07T12:06:50.8274230Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":32,"playSeq":23,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:50.8434224Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"148","key2":"321","key3":"0","key4":"149","key5":"305","key6":"228332150"}}}
{"ts":"2026-06-07T12:06:50.8594230Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":31,"playSeq":24,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:50.9024228Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[11],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}}],"actionSeq":19}}
{"ts":"2026-06-07T12:06:55.3169242Z","direction":"send","uri":"PlayActions","body":{"playIdx":10,"orderList":[{"move":{"idx":[10],"isSelf":1,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:06:56.0779247Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,16,15,24,11],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":3}}]}}
{"ts":"2026-06-07T12:06:56.5774224Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"150","key2":"302","key3":"101121116","key4":"149","key5":"305","key6":"228332150"},"type":0,"actionSeq":22,"cemetery":[1,1]}}
{"ts":"2026-06-07T12:06:57.6284227Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":37,"playSeq":25,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:06:57.6504253Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[39],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
{"ts":"2026-06-07T12:07:02.6859240Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":38,"playSeq":26,"playIdx":39,"type":30,"knownList":[{"idx":39,"cardId":100314010,"to":30,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
{"ts":"2026-06-07T12:07:02.8454236Z","direction":"send","uri":"Echo","body":{"playIdx":39,"orderList":[{"move":{"idx":[39],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[1,4,5,19,41],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"7"}},{"move":{"idx":[17],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}},{"trigger":{"isSelf":0,"avarice":1}}],"type":30}}
{"ts":"2026-06-07T12:07:10.2264230Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":39,"playSeq":27,"playIdx":41,"type":30,"knownList":[{"idx":41,"cardId":121011010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
{"ts":"2026-06-07T12:07:10.3164226Z","direction":"send","uri":"Echo","body":{"playIdx":41,"orderList":[{"move":{"idx":[41],"isSelf":0,"from":10,"to":20}},{"move":{"idx":[6],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}],"type":30}}
{"ts":"2026-06-07T12:07:17.7599274Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":40,"playSeq":28}}
{"ts":"2026-06-07T12:07:17.7789256Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[1,4,5,19,17,6],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":3}},{"trigger":{"isSelf":0,"avarice":0}}]}}
{"ts":"2026-06-07T12:07:18.2769237Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":41,"playSeq":29,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:07:18.2949243Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"150","key2":"302","key3":"101121116","key4":"147","key5":"221","key6":"349343345"}}}
{"ts":"2026-06-07T12:07:18.3089265Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":40,"playSeq":30,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:07:18.3409222Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[6],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":26}}
{"ts":"2026-06-07T12:07:22.0604232Z","direction":"send","uri":"PlayActions","body":{"playIdx":6,"orderList":[{"move":{"idx":[6],"isSelf":1,"from":10,"to":20}},{"move":{"idx":[34],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}},{"trigger":{"isSelf":1,"avarice":1}}],"uList":[{"idxList":[34],"from":0,"to":10,"isSelf":1,"skill":"6|19|0"}],"type":30}}
{"ts":"2026-06-07T12:07:25.6229734Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,16,15,24,11,34],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":3}},{"trigger":{"isSelf":1,"avarice":0}}]}}
{"ts":"2026-06-07T12:07:26.1224220Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"149","key2":"540","key3":"214132162","key4":"147","key5":"221","key6":"349343345"},"type":0,"actionSeq":29,"cemetery":[1,2]}}
{"ts":"2026-06-07T12:07:26.2219233Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":46,"playSeq":31,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:07:26.2444230Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[32],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
{"ts":"2026-06-07T12:07:34.2504226Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":49,"playSeq":32,"playIdx":1,"type":31,"knownList":[{"idx":1,"cardId":101324040,"to":30,"spellboost":0,"attachTarget":"","cost":3,"clan":3,"tribe":"0"}],"oppoTargetList":[{"targetIdx":6,"isSelf":1}]}}
{"ts":"2026-06-07T12:07:34.4124257Z","direction":"send","uri":"Echo","body":{"playIdx":1,"orderList":[{"move":{"idx":[1],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[4,5,19,17,6,32],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"20"}},{"move":{"idx":[23],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}},{"trigger":{"isSelf":0,"avarice":1}}],"type":31}}
{"ts":"2026-06-07T12:07:41.2491729Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":50,"playSeq":33,"playIdx":17,"type":30,"knownList":[{"idx":17,"cardId":102324040,"to":30,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
{"ts":"2026-06-07T12:07:41.4554809Z","direction":"send","uri":"Echo","body":{"playIdx":17,"orderList":[{"move":{"idx":[17],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[4,5,19,6,32,23],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"23"}},{"add":{"idx":[42],"isSelf":0,"card":{"cardId":900311050}}},{"move":{"idx":[42],"isSelf":0,"from":50,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:07:46.6891818Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":52,"playSeq":34,"playIdx":41,"type":10,"oppoTargetList":[{"targetIdx":10,"isSelf":0}]}}
{"ts":"2026-06-07T12:07:46.7161815Z","direction":"send","uri":"Echo","body":{"playIdx":41,"type":10}}
{"ts":"2026-06-07T12:07:48.2401820Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":54,"playSeq":35,"playIdx":29,"type":10,"knownList":[{"idx":29,"cardId":127011010,"to":30,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"oppoTargetList":[{"targetIdx":10,"isSelf":0}]}}
{"ts":"2026-06-07T12:07:48.3302904Z","direction":"send","uri":"Echo","body":{"playIdx":29,"orderList":[{"move":{"idx":[29],"isSelf":0,"from":20,"to":30}},{"move":{"idx":[10],"isSelf":1,"from":20,"to":30}}],"type":10}}
{"ts":"2026-06-07T12:07:50.0089639Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":56,"playSeq":36,"playIdx":3,"type":10,"knownList":[{"idx":3,"cardId":101321040,"to":30,"spellboost":0,"attachTarget":"","cost":2,"clan":3,"tribe":"0"}],"oppoTargetList":[{"targetIdx":6,"isSelf":0}]}}
{"ts":"2026-06-07T12:07:50.2322631Z","direction":"send","uri":"Echo","body":{"playIdx":3,"orderList":[{"move":{"idx":[3],"isSelf":0,"from":20,"to":30}},{"move":{"idx":[6],"isSelf":1,"from":20,"to":30}}],"type":10}}
{"ts":"2026-06-07T12:07:51.8934054Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":57,"playSeq":37}}
{"ts":"2026-06-07T12:07:52.0776073Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[4,5,19,6,32,23],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":5}},{"trigger":{"isSelf":0,"avarice":0}}]}}
{"ts":"2026-06-07T12:07:52.3771546Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":58,"playSeq":38,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:07:52.3931550Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"153","key2":"540","key3":"0","key4":"154","key5":"393","key6":"1021322270"}}}
{"ts":"2026-06-07T12:07:52.4097475Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":52,"playSeq":39,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:07:52.4689367Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":6}},{"move":{"idx":[18],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":36}}
{"ts":"2026-06-07T12:07:57.1625968Z","direction":"send","uri":"PlayActions","body":{"playIdx":34,"orderList":[{"move":{"idx":[34],"isSelf":1,"from":10,"to":20}},{"move":{"idx":[5],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":1}},{"trigger":{"isSelf":1,"avarice":1}}],"uList":[{"idxList":[5],"from":0,"to":10,"isSelf":1,"skill":"34|28|0"}],"type":30}}
{"ts":"2026-06-07T12:07:58.2473269Z","direction":"send","uri":"PlayActions","body":{"playIdx":18,"orderList":[{"move":{"idx":[18],"isSelf":1,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:08:02.7615951Z","direction":"send","uri":"PlayActions","body":{"playIdx":5,"orderList":[{"move":{"idx":[5],"isSelf":1,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:08:05.7352832Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,16,15,24,11],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":1}},{"move":{"idx":[42],"isSelf":0,"from":20,"to":30,"hasGuard":[42]}},{"trigger":{"isSelf":1,"avarice":0}}]}}
{"ts":"2026-06-07T12:08:06.2301214Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"152","key2":"302","key3":"326133205","key4":"156","key5":"393","key6":"121011060"},"type":0,"actionSeq":41,"cemetery":[3,7]}}
{"ts":"2026-06-07T12:08:06.3303197Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":65,"playSeq":40,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:08:06.3513355Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[38],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":0}}]}}
{"ts":"2026-06-07T12:08:17.8463749Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":68,"playSeq":41,"playIdx":19,"type":30,"knownList":[{"idx":19,"cardId":127011010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"keyAction":[{"type":1,"cardId":127011010}]}}
{"ts":"2026-06-07T12:08:17.9224312Z","direction":"send","uri":"Echo","body":{"playIdx":19,"orderList":[{"move":{"idx":[19],"isSelf":0,"from":10,"to":20}},{"add":{"idx":[43],"isSelf":0,"card":{"candidates":[121011010,120011010]},"isChoice":"1"}},{"move":{"idx":[43],"isSelf":0,"from":50,"to":10}}],"type":30}}
{"ts":"2026-06-07T12:08:21.3856074Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":71,"playSeq":42,"playIdx":4,"type":31,"knownList":[{"idx":4,"cardId":101324050,"to":30,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}],"oppoTargetList":[{"targetIdx":5,"isSelf":0}]}}
{"ts":"2026-06-07T12:08:21.5844099Z","direction":"send","uri":"Echo","body":{"playIdx":4,"orderList":[{"move":{"idx":[4],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[5,6,32,23,38,43],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"33"}},{"metamorphose":{"idx":[5],"isSelf":1,"after":{"cardId":900311020}}}],"type":31}}
{"ts":"2026-06-07T12:08:25.9743530Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":74,"playSeq":43,"playIdx":5,"type":31,"knownList":[{"idx":5,"cardId":101334030,"to":30,"spellboost":2,"attachTarget":"","cost":3,"clan":3,"tribe":"0"}],"oppoTargetList":[{"targetIdx":34,"isSelf":0}]}}
{"ts":"2026-06-07T12:08:26.1638091Z","direction":"send","uri":"Echo","body":{"playIdx":5,"orderList":[{"move":{"idx":[5],"isSelf":0,"from":10,"to":30}},{"alter":{"idx":[6,32,23,38,43],"isSelf":0,"type":"add","spellboost":"a1","attachTarget":"36"}},{"move":{"idx":[34],"isSelf":1,"from":20,"to":30}},{"add":{"idx":[44],"isSelf":0,"card":{"cardId":900334010}}},{"move":{"idx":[44],"isSelf":0,"from":50,"to":10}}],"type":31}}
{"ts":"2026-06-07T12:08:29.6025555Z","direction":"receive","uri":null,"body":{"uri":"TurnEndActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":75,"playSeq":44}}
{"ts":"2026-06-07T12:08:29.6190527Z","direction":"send","uri":"Echo","body":{"orderList":[{"trigger":{"isSelf":0,"turnEndRestore":[6,32,23,38,43,44],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"maxAtk":3}}]}}
{"ts":"2026-06-07T12:08:30.1015223Z","direction":"receive","uri":null,"body":{"uri":"TurnEnd","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":76,"playSeq":45,"turnState":0,"resultCode":1}}
{"ts":"2026-06-07T12:08:30.1180409Z","direction":"send","uri":"Judge","body":{"battleCode":{"key1":"154","key2":"302","key3":"1000422107","key4":"162","key5":"770","key6":"248022140"}}}
{"ts":"2026-06-07T12:08:30.1345601Z","direction":"receive","uri":null,"body":{"uri":"Judge","viewerId":6,"uuid":"10d8e723-ac93-47a5-b307-cf3a0d6026e4","try":1,"cat":1,"bid":"907324319325","pubSeq":64,"playSeq":46,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:08:30.1768361Z","direction":"send","uri":"TurnStart","body":{"orderList":[{"playerParam":{"isSelf":1,"maxPP":1}},{"trigger":{"isSelf":1,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":0,"turnDamageFromUnit":0}},{"move":{"idx":[35],"isSelf":1,"from":0,"to":10}},{"trigger":{"isSelf":1,"resonance":0}}],"actionSeq":46}}
{"ts":"2026-06-07T12:08:37.7130477Z","direction":"send","uri":"PlayActions","body":{"playIdx":15,"orderList":[{"move":{"idx":[15],"isSelf":1,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:08:38.5902629Z","direction":"send","uri":"TurnEndActions","body":{"orderList":[{"trigger":{"isSelf":1,"turnEndRestore":[2,3,16,24,11,35],"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"maxAtk":5}}]}}
{"ts":"2026-06-07T12:08:39.0894170Z","direction":"send","uri":"TurnEnd","body":{"battleCode":{"key1":"156","key2":"417","key3":"1101543355","key4":"162","key5":"770","key6":"248022140"},"type":0,"actionSeq":49,"cemetery":[4,9]}}
{"ts":"2026-06-07T12:08:40.0572510Z","direction":"receive","uri":null,"body":{"uri":"TurnStart","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":81,"playSeq":47,"spin":0,"resultCode":1}}
{"ts":"2026-06-07T12:08:40.0784574Z","direction":"send","uri":"Echo","body":{"orderList":[{"playerParam":{"isSelf":0,"maxPP":1}},{"trigger":{"isSelf":0,"turnStartRestore":1,"canEvolve":1,"isUnlimited":1}},{"trigger":{"isSelf":1,"turnDamageFromUnit":0}},{"move":{"idx":[20],"isSelf":0,"from":0,"to":10}},{"trigger":{"isSelf":0,"resonance":1}}]}}
{"ts":"2026-06-07T12:08:44.0705950Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":82,"playSeq":48,"playIdx":20,"type":30,"knownList":[{"idx":20,"cardId":101311010,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
{"ts":"2026-06-07T12:08:44.2734716Z","direction":"send","uri":"Echo","body":{"playIdx":20,"orderList":[{"move":{"idx":[20],"isSelf":0,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:08:49.2868793Z","direction":"receive","uri":null,"body":{"uri":"PlayActions","viewerId":7,"uuid":"a7ee9b5f-b0c5-48de-82b1-1a2468cfc696","try":1,"cat":1,"bid":"907324319325","pubSeq":83,"playSeq":49,"playIdx":23,"type":30,"knownList":[{"idx":23,"cardId":101321070,"to":20,"spellboost":0,"attachTarget":"","cost":0,"clan":0,"tribe":"0"}]}}
{"ts":"2026-06-07T12:08:49.5008504Z","direction":"send","uri":"Echo","body":{"playIdx":23,"orderList":[{"move":{"idx":[23],"isSelf":0,"from":10,"to":20}}],"type":30}}
{"ts":"2026-06-07T12:09:11.1269227Z","direction":"receive","uri":null,"body":{"uri":"BattleFinish","viewerId":999999999,"uuid":"node-stub","try":0,"cat":1,"result":201,"resultCode":1}}

View File

@@ -0,0 +1,151 @@
using System.Reflection;
using NUnit.Framework;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M11 (the GATE itself is the oracle): every prior milestone either had no skill_condition or
// seeded its gate TRUE so the effect fires (M4 seeded play_count>2; M10 seeded a play_count
// VALUE). None proved the engine SUPPRESSES an effect when a skill_condition evaluates FALSE —
// the dual of "effect fires". M11 proves conditional BRANCHING resolves headless by asserting
// BOTH directions of the SAME gated card in ONE fixture (design "M11 — NEXT" resume guide):
//
// * gate TRUE (play_count > 2, seeded via the public AddCurrentTrunPlayCount seam M4/M10 use)
// -> the when_play powerup fires -> the follower is buffed over its base stats.
// * gate FALSE (play_count <= 2, the bare-construction default)
// -> the powerup is a NO-OP: zero stat delta, BUT the card still pays its cost
// and still leaves hand -> board (the gate suppresses the EFFECT, not the PLAY).
//
// Card: 103111050 — the M4 self-buff follower (ELF clan-1 cost-1 base 1/1, sole non-evo skill
// `when_play` `powerup` `add_offense=1&add_life=1` to `character=me&target=self`), whose
// skill_condition is `character=me&target=self&play_count>2` (verified in cards.json). The gate
// reads BattlePlayerBase.GetCurrentTurnPlayCount(), seedable past/below the threshold via the
// public AddCurrentTrunPlayCount. Reusing the M4-proven buff DIMENSION means the only NEW thing
// under test is the CONDITIONAL — exactly the resume-guide's "proven effect dimension, gate is
// the oracle" prescription.
//
// Why one fixture, both branches, ONE card is decisive: the two assertions are jointly
// satisfiable ONLY by a correctly-gating engine. An "always-buffs" engine fails the FALSE branch
// (would buff with play_count=0); a "never-buffs" engine fails the TRUE branch (M4's gate seed
// wouldn't fire). M4 already demonstrated this split as a manual load-bearing probe (remove the
// seed -> buff vanishes); M11 promotes it to the PRIMARY assertion.
[TestFixture]
public class GatedConditionalOracleTests
{
// A clearly super-threshold seed (play_count 5 > 2): the gate evaluates TRUE, fanfare fires.
private const int GateTrueSeed = 5;
// The bare-construction default is play_count 0 (<= 2 -> gate FALSE); we seed nothing for the
// FALSE branch, exactly as M4's load-bearing probe did when it removed its seed.
private const int GateFalseSeed = 0;
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
private static void SetPrivateField(object obj, string name, object value)
{
var t = obj.GetType();
var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); }
Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}");
f.SetValue(obj, value);
}
// Resolve the gated self-buff follower on a FRESH battle with the per-turn play count seeded
// to `seededPlayCount`, and report the play's outcome. A fresh mgr per branch is required:
// play_count is per-mgr state and a resolved play mutates the board, so the two branches must
// not share a battle. Mirrors the M4 BuffFollowerOracleTests setup verbatim, parameterized on
// the seed (which is the only thing M11 varies between branches).
private (BattleCardBase card, CardParameter param, int ppBefore, int ppAfter,
int handBefore, bool inHandAfter, int inplayBefore, bool onBoardAfter, int inplayAfter)
PlayGatedSelfBuff(int seededPlayCount)
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr; // route GetIns() to this branch's mgr
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
// Minimal opponent/turn wiring (M2/M3/M4 oracles): opponent refs + active turn flag. The
// self-buff target resolver (`character=me&target=self`) reads the active player's own
// in-play card, so the turn flag must be set before the fanfare sweeps.
SetPrivateField(player, "_opponentBattlePlayer", enemy);
SetPrivateField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
// Seed leader life so neither leader reads as a 0-life game-over that silently blocks the
// play (M3 learning). This card deals no damage but the play-legality gate still checks it.
HeadlessEngineEnv.InitLeaderLife(mgr);
// THE GATE SEED — the one knob M11 turns between branches. The skill_condition
// `play_count>2` reads BattlePlayerBase.GetCurrentTurnPlayCount(); seed it via the public
// AddCurrentTrunPlayCount (M4/M10 seam). For the FALSE branch we leave the bare default 0.
if (seededPlayCount > 0) player.AddCurrentTrunPlayCount(seededPlayCount);
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.BuffFollowerId);
// Place the gated self-buff follower in the active player's hand with PP to spare; empty board.
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.BuffFollowerId, 1, isPlayer: true, mgr);
player.HandCardList.Add(card);
player.Pp = 10;
int ppBefore = player.Pp;
int handBefore = player.HandCardList.Count;
int inplayBefore = player.ClassAndInPlayCardList.Count;
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
var ap = new ActionProcessor(pair);
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null),
$"ActionProcessor.PlayCard threw on the gated self-buff (seed={seededPlayCount})");
return (card, cardParam, ppBefore, player.Pp,
handBefore, player.HandCardList.Contains(card),
inplayBefore, player.ClassAndInPlayCardList.Contains(card),
player.ClassAndInPlayCardList.Count);
}
[Test]
public void Gated_fanfare_fires_when_seeded_true_and_is_suppressed_when_false()
{
// ----- Branch 1: gate TRUE (play_count 5 > 2) -> the fanfare FIRES (M4 dimension). -----
var t = PlayGatedSelfBuff(GateTrueSeed);
// ----- Branch 2: gate FALSE (play_count 0 <= 2) -> the fanfare is SUPPRESSED. -----
var f = PlayGatedSelfBuff(GateFalseSeed);
Assert.Multiple(() =>
{
// PRIMARY M11 assertion — the gate itself: SAME card, opposite stat outcomes driven
// ONLY by the seeded condition.
// TRUE -> buffed: base 1/1 + 1/1 = 2/2.
Assert.That(t.card.Atk, Is.EqualTo(t.param.Atk + HeadlessEngineEnv.BuffAddOffense),
"[gate TRUE] atk != base + add_offense (fanfare should have fired)");
Assert.That(t.card.Life, Is.EqualTo(t.param.Life + HeadlessEngineEnv.BuffAddLife),
"[gate TRUE] life != base + add_life (fanfare should have fired)");
// FALSE -> unbuffed: stays at the CardCSVData base 1/1 (effect suppressed).
Assert.That(f.card.Atk, Is.EqualTo(f.param.Atk),
"[gate FALSE] atk != base (fanfare should have been gated out)");
Assert.That(f.card.Life, Is.EqualTo(f.param.Life),
"[gate FALSE] life != base (fanfare should have been gated out)");
// The gate suppresses the EFFECT, not the PLAY: in BOTH branches the card still pays
// its cost and still moves hand -> board like any follower.
// TRUE branch:
Assert.That(t.ppAfter, Is.EqualTo(t.ppBefore - t.param.Cost), "[gate TRUE] PP not reduced by cost");
Assert.That(t.inHandAfter, Is.False, "[gate TRUE] card still in hand");
Assert.That(t.onBoardAfter, Is.True, "[gate TRUE] card not on board");
Assert.That(t.inplayAfter, Is.EqualTo(t.inplayBefore + 1), "[gate TRUE] in-play count not +1");
// FALSE branch — the M11 crux: cost STILL paid + card STILL resolves despite the no-op effect.
Assert.That(f.ppAfter, Is.EqualTo(f.ppBefore - f.param.Cost), "[gate FALSE] PP not reduced by cost");
Assert.That(f.inHandAfter, Is.False, "[gate FALSE] card still in hand");
Assert.That(f.onBoardAfter, Is.True, "[gate FALSE] card not on board");
Assert.That(f.inplayAfter, Is.EqualTo(f.inplayBefore + 1), "[gate FALSE] in-play count not +1");
});
}
}
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Serialization;
using System.Text.Json;
using Wizard;
namespace SVSim.BattleEngine.Tests
{
// Populates the engine's static CardMaster headless, from the loader's cards.json dump
// (serialized CardCSVData objects). We bypass the network/Resources init path
// (CardMaster.InitializeCardMaster) and the private ctor/field via reflection — CardMaster
// exposes no public injection seam. Class cards (id < 100) resolve via the ctor's
// _classCardParam, so an empty load still satisfies construction; pass real ids for the oracle.
public static class HeadlessCardMaster
{
private static readonly string CardsJsonPath =
Path.Combine(AppContext.BaseDirectory, "Data", "cards.json");
// Every id ever requested this process. Load is CUMULATIVE: each call rebuilds the master from
// the union, so a later Load(subset) never evicts cards an earlier Load (e.g. EnsureProcessGlobals's
// oracle set) installed. Without this, the static CardMaster is shared mutable state across the
// whole NUnit run and a Load(deck) in one test silently breaks an oracle test that runs after.
private static readonly HashSet<int> _everLoaded = new();
// Serialise Load: assembly-level Parallelizable(Fixtures) means concurrent fixtures race here,
// and HashSet<int>.Add + the static CardMaster install are not thread-safe.
private static readonly object _loadGate = new object();
// Load the given card ids (empty = none) into a CardMaster registered as Default, MERGED with all
// previously-loaded ids.
public static void Load(params int[] cardIds)
{
lock (_loadGate)
{
LoadCore(cardIds);
}
}
private static void LoadCore(int[] cardIds)
{
foreach (var id in cardIds) _everLoaded.Add(id);
var want = new HashSet<int>(_everLoaded);
var rows = new List<CardCSVData>();
if (want.Count > 0)
{
using var doc = JsonDocument.Parse(File.ReadAllText(CardsJsonPath));
int sort = 0;
foreach (var el in doc.RootElement.EnumerateArray())
{
if (!el.TryGetProperty("card_id", out var idEl)) continue;
if (!int.TryParse(idEl.GetString(), out var id) || !want.Contains(id)) continue;
rows.Add(BuildCardCsvData(el, sort++));
}
var missing = want.Except(rows.Select(r => int.Parse(r.card_id))).ToArray();
if (missing.Length > 0)
throw new InvalidOperationException(
"cards.json missing requested ids: " + string.Join(",", missing));
}
var cm = NewCardMaster(rows);
InjectAsDefault(cm);
}
// Construct a CardCSVData without running its CSV ctor; set each member from the JSON object
// by exact name match (cards.json keys == CardCSVData member names).
private static CardCSVData BuildCardCsvData(JsonElement el, int sortIndex)
{
var c = (CardCSVData)FormatterServices.GetUninitializedObject(typeof(CardCSVData));
const BindingFlags bf = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
foreach (var prop in el.EnumerateObject())
{
string val = prop.Value.ValueKind == JsonValueKind.Null ? null : prop.Value.ToString();
var f = typeof(CardCSVData).GetField(prop.Name, bf);
if (f != null) { SetMember(f.FieldType, val, v => f.SetValue(c, v)); continue; }
var p = typeof(CardCSVData).GetProperty(prop.Name, bf);
if (p != null && p.CanWrite) SetMember(p.PropertyType, val, v => p.SetValue(c, v));
}
// SortIndex is normally set by the ctor; mirror it.
var si = typeof(CardCSVData).GetProperty("SortIndex", bf);
if (si != null && si.CanWrite) si.SetValue(c, sortIndex);
return c;
}
private static void SetMember(Type t, string val, Action<object> set)
{
if (t == typeof(string)) set(val);
else if (t == typeof(int)) set(int.TryParse(val, out var i) ? i : 0);
else if (t == typeof(bool)) set(val == "1" || string.Equals(val, "true", StringComparison.OrdinalIgnoreCase));
// other types left at default
}
private static CardMaster NewCardMaster(List<CardCSVData> rows)
{
var ctor = typeof(CardMaster).GetConstructor(
BindingFlags.Instance | BindingFlags.NonPublic, null,
new[] { typeof(List<CardCSVData>) }, null);
if (ctor == null) throw new InvalidOperationException("CardMaster(List<CardCSVData>) ctor not found");
return (CardMaster)ctor.Invoke(new object[] { rows });
}
private static void InjectAsDefault(CardMaster cm)
{
var idType = typeof(CardMaster).GetNestedType("CardMasterId");
var defaultId = Enum.Parse(idType, "Default");
var dictType = typeof(Dictionary<,>).MakeGenericType(idType, typeof(CardMaster));
var dict = (System.Collections.IDictionary)Activator.CreateInstance(dictType);
dict[defaultId] = cm;
var fld = typeof(CardMaster).GetField("_dictCardMaster",
BindingFlags.Static | BindingFlags.NonPublic);
fld.SetValue(null, dict);
}
}
}

View File

@@ -0,0 +1,557 @@
using System.Reflection;
using SVSim.BattleEngine.Rng;
using UnityEngine;
using Wizard;
using Wizard.Battle;
using Wizard.Battle.Phase;
using Wizard.Battle.Recovery;
using Wizard.Battle.Replay;
using Wizard.Battle.Resource;
using Wizard.Battle.View.Vfx;
using Wizard.BattleMgr;
namespace SVSim.BattleEngine.Tests
{
// Initializes the global engine state a headless battle assumes exists. In the real client this
// is populated from /load/index at login; here we author the minimum the resolution path reads.
public static class HeadlessEngineEnv
{
// Simplest zero-skill vanilla follower in cards.json: neutral (clan 0), cost 1, 1/2, no skill.
public const int FollowerId = 100011010;
// M3 next-hardest deterministic card: a fixed-damage spell. 900124030 is an ELF (clan 1, matches
// PlayerClassId) cost-3 spell whose sole skill is `when_play` `damage=3` to `card_type=class`
// (the enemy leader) — auto-targeted (no select_count), no RNG. Deterministic burn to the face.
public const int SpellId = 900124030;
// M4 next-hardest deterministic card: a when_play SELF-BUFF follower. 103111050 is an ELF
// (clan 1) cost-1 1/1 whose sole non-evo skill is `when_play` `powerup` `add_offense=1&add_life=1`
// with skill_target `character=me&target=self` — it buffs ITSELF, so no target selection (the
// fanfare auto-resolves). Fixed +1/+1 => a deterministic stat-delta oracle. The skill is gated on
// `play_count>2`; the headless harness seeds that via the public AddCurrentTrunPlayCount (see the
// oracle test). Base 1/1 -> 2/2 after the fanfare.
public const int BuffFollowerId = 103111050;
public const int BuffAddOffense = 1;
public const int BuffAddLife = 1;
// M5 next-hardest deterministic card: a when_play SUMMON_TOKEN spell. 800134010 is an ELF
// (clan 1) cost-1 spell whose sole skill is `when_play` `summon_token=100011020` with
// `skill_target=none` and an UNGATED condition (`character=me`, trivially the caster): it
// summons exactly ONE neutral 2/2 follower TOKEN onto the caster's board — no target
// selection, no RNG (Skill_summon_token's random branch is `num >= 0 && !IsForecast`, and
// this option carries no `random_count`, so num=-1 => the deterministic literal-id path).
// The new oracle dimension over M2/M3/M4 is a BOARD-COUNT DELTA from a SKILL-CREATED card:
// a token that was never in the hand/deck appears in play. This is also the first headless
// exercise of the PUBLIC prefab card-creation path (CardCreatorBase.CreateCard,
// createNullView:false, via BattlePlayerBase.CreateNextIndexCard) — class-card construction
// hits `default: return null` and the M2-M4 hand cards used the private null-view seam, so
// the view-building creation path is genuinely new here.
public const int TokenSpellId = 800134010;
public const int SummonedTokenId = 100011020; // neutral 2/2 follower token
public const int SummonedTokenAtk = 2;
public const int SummonedTokenLife = 2;
// M6 next milestone: the first card requiring TARGET SELECTION — exercises the selectedCards
// path of ActionProcessor.PlayCard (dormant through M2-M5, all of which played
// selectedCards: null). 800134020 is an ELF (clan 1) cost-1 SPELL whose sole skill is
// `when_play` `damage=5` to a SELECTED enemy follower
// (skill_target=character=op&target=inplay&card_type=unit&select_count=1), ungated
// (character=me), no RNG, no dynamic `{}` value. The new oracle dimension is SELECTION
// ROUTING: with TWO followers on the enemy board and ONE passed as selectedCards, only the
// selected follower takes the 5 damage and the un-selected one is untouched.
public const int TargetSpellId = 800134020;
public const int TargetSpellDamage = 5;
// Two zero-skill vanilla NEUTRAL followers placed on the ENEMY board. Both have life > the
// 5 damage so they SURVIVE — this gives a differential life-delta oracle (selected -5,
// un-selected -0) that reads the authoritative damage path M3 already proved, without
// depending on follower death/board-removal timing (a separate, unproven mechanic). Distinct
// base life (13 vs 7) so the two post-states can't coincidentally match.
public const int SelectTargetFollowerId = 900041010; // neutral 13/13
public const int UnselectTargetFollowerId = 102011010; // neutral 6/7
// M7 next milestone: targeted DESTROY — the first card proving follower DEATH / board-removal
// resolves in the AUTHORITATIVE (committed) part of PlayCard headless, not the cosmetic
// post-Process tail. 800144120 is an ELF (clan 1) cost-0 SPELL whose sole skill is `when_play`
// `destroy` of a SELECTED enemy follower
// (skill_target=character=op&target=inplay&card_type=unit&select_count=1), ungated
// (skill_condition=character=me), no RNG, no dynamic value. `destroy` is UNCONDITIONAL removal
// (vs `damage` needing a >=life amount), so the oracle is the cleanest possible "card left the
// board": selected follower gone + enemy board count -1 + selected card in CemeteryList, while
// the un-selected follower stays (routing, M6's lesson, confirmed load-bearing by swapping the
// selection). Reuses the two M2/M6 vanilla followers as the target board (destroy is
// unconditional so their stats are irrelevant — distinct ids only so selected vs un-selected
// can't be confused). InitCardTemplates is NOT needed (destroy creates no card).
public const int DestroySpellId = 800144120;
public const int DestroyTargetFollowerId = FollowerId; // neutral 1/2 (the selected, destroyed one)
public const int DestroyOtherFollowerId = UnselectTargetFollowerId; // neutral 6/7 (the un-selected survivor)
// M8 next milestone: LETHAL damage — proves follower DEATH VIA COMBAT MATH (damage >= life ->
// 0 life -> the same RemoveInplayCard/cemetery death path M7 lit up via `destroy`, but reached
// through the dominant real-card mechanic: "deal N damage"). Reuses the M6 damage=5 spell
// (800134020) but with target followers STRADDLING 5 life so the SAME spell kills one and merely
// chips the other in a single oracle: the SELECTED target has life <= 5 and dies (board -1 +
// cemetery +1, the M7 assertions), while the UN-SELECTED control has life > 5 and survives at
// reduced life (the M6 life-delta assertion). This combines M7's removal dimension with M6's
// life-delta + routing, and distinguishes death-via-damage from the unconditional `destroy`.
public const int LethalDamageSpellId = TargetSpellId; // 800134020, when_play damage=5
public const int LethalDamage = TargetSpellDamage; // 5
public const int LethalTargetFollowerId = FollowerId; // neutral 1/2 (life 2 <= 5 -> dies)
public const int SurvivorTargetFollowerId = UnselectTargetFollowerId; // neutral 6/7 (life 7 > 5 -> survives at 2)
// M9 next milestone: when_play DRAW — proves the HAND/DECK DELTA dimension (design §5's draw
// oracle): the last deterministic, non-RNG card-effect class no prior milestone touched (M3/M4/
// M6/M8 moved stats, M2/M5/M7 the board, M3 the leader — none read the deck->hand transfer).
// 800114010 is an ELF (clan 1) cost-1 SPELL whose sole skill is `when_play` `draw` of ONE card
// from the caster's own deck (skill_target=character=me&target=deck&card_type=all&random_count=1),
// ungated (skill_condition=character=me), no evo skill, no preprocess, no dynamic `{}` value.
//
// ADAPTATION FROM THE RESUME-GUIDE SHAPE: the guide asked for a `skill_target=none` draw with
// "no RNG", but no such card exists in cards.json — EVERY draw selects from the deck via a
// `random_count=N` target filter (skill_option is always literally `none`; the count lives in
// skill_target). The RNG is neutralized structurally instead: seed the deck with EXACTLY ONE
// known card, so `random_count=1` over a single-card pool is deterministic regardless of the
// RandomSeed. This keeps the oracle decisive (drawn id is forced) while exercising the real
// draw path. Like the summon token, a drawn card is engine-CREATED off the deck the M5 prefab
// way; unlike summon, the card already exists (we seed it) and the skill only MOVES it deck->hand.
public const int DrawSpellId = 800114010;
public const int DeckSeedCardId = FollowerId; // the single known deck card (neutral 1/2 vanilla)
// M10 next milestone: the first DYNAMIC `{}`-VALUE card — proves the engine COMPUTES an effect
// magnitude from live game state (a value the wire can't carry; per memory
// project_battle_relay_nontargeted_effects this state-derived-value problem is exactly what
// broke the PvP relay, so proving the engine resolves it headless is the direct validation that
// the port — not a relay — is the necessary path). Still non-RNG: a seeded state makes the value
// deterministic. 112134010 is an ELF (clan 1) cost-2 SPELL whose sole skill is `when_play`
// `damage={me.play_count}-1` to `character=both&target=inplay&card_type=unit` (with a
// `base_card_id!=900111010|900111020` exclusion) — an AoE over BOTH boards' units, auto-targeted
// (no select_count, so selectedCards: null like M2-M5), ungated (skill_condition=character=me).
//
// The `{}` value resolves (SkillOptionValue.ParseInt) as
// `_filterVariable.Parse("me.play_count") - 1`, where Parse routes to
// SkillEnvironmentalPlayCount.Filtering -> playerInfo.GetCurrentTurnPlayCount() (the
// `isPrePlay=false` resolution path). That is the SAME per-turn counter the public
// AddCurrentTrunPlayCount feeds (M4 proved this seam drove the play_count>2 GATE; M10 proves it
// also feeds the `{}` VALUE). The per-play auto-increment AddCurrentTrunPlayCount(1) lives in
// ActionProcessor's OnBeforePlayCard (BattlePlayerBase.cs:1400), subscribed by
// SetupActionProcessorEvent — which is ONLY called on the OperateMgr/Prediction/OperationSimulator
// paths, NOT on the direct `new ActionProcessor(pair).PlayCard` (DP4) path this harness uses. So
// the headless play does NOT self-bump the per-turn count: the skill reads EXACTLY the seeded
// GetCurrentTurnPlayCount() and the damage == seeded - 1. The oracle derives the expected
// magnitude from the engine's OWN live GetCurrentTurnPlayCount(), not from a hardcoded literal,
// which is the M10 dimension (engine-computed value, not a wire-carried constant).
//
// The target is the M6 vanilla NEUTRAL 13/13 follower (SelectTargetFollowerId, already loaded):
// life 13 > any reasonable seeded count, so it SURVIVES for a clean life-delta read (reusing the
// M3/M6/M8 damage->life path), and `card_type=unit` excludes both leaders (asserted untouched).
public const int DynamicDamageSpellId = 112134010;
public const int DynamicDamageTargetFollowerId = SelectTargetFollowerId; // neutral 13/13 (survives, clean delta)
// A deliberately non-trivial seeded per-turn play count so the computed damage (== this value)
// is an obvious state read, not a coincidence with a small literal. The load-bearing probe
// (M4/M6/M8 discipline) varies this and watches the damage track it.
public const int DynamicSeededPlayCount = 4;
// M12 (the design §5 RNG oracle): reuse the M9 draw spell (800114010, when_play `draw` 1 from the
// caster's deck via a random_count=1 filter) but over a MULTI-card deck with IsRandomDraw=true.
// M9 passed only because IsRandomDraw=false takes BattlePlayerBase.LotteryRandomDrawCard's
// top-of-deck `else` branch (BattlePlayerBase.cs:3174-3185) — a 1-card pool made index 0 the only
// card. With IsRandomDraw=true the selection runs through SkillRandomSelectFilter.Filtering, which
// calls BattleManagerBase.GetIns().StableRandom(poolCount) per pick (SkillRandomSelectFilter.cs:42,
// gated on IsRandomDraw) — the chokepoint HeadlessBattleMgr overrides. So the scripted source picks
// exactly which deck card is drawn, proving a GENUINE multi-outcome roll (the dimension M9's
// one-card pool deliberately avoided).
//
// Three distinguishable deck cards seeded at consecutive indices; SkillRandomSelectFilter orders
// the pool by Index (line 34), so the pick index maps to position in this order:
// index 0 -> RngDeckCardA (100011010), index 1 -> RngDeckCardB (103111050), index 2 -> RngDeckCardC (100011020)
// All three are already loaded by HeadlessCardMaster.Load via EnsureInitialized (FollowerId,
// BuffFollowerId, SummonedTokenId), so no Load change is needed.
public const int RngDrawSpellId = DrawSpellId; // 800114010, when_play draw 1 (random_count=1)
public const int RngDeckCardA = FollowerId; // neutral 1/2 -> Index-order position 0
public const int RngDeckCardB = BuffFollowerId; // ELF 1/1 -> Index-order position 1
public const int RngDeckCardC = SummonedTokenId; // neutral 2/2 -> Index-order position 2
private static bool _done;
private static readonly object _processGlobalsGate = new object();
// Process-globals only: load card master, install master data, seed LoadDetail/Crossover,
// seed Certification.udid. Per-battle/per-test state (IsForecast, chara ids on the DataMgr,
// NetworkUserInfoData) is now seeded inside TestBattleScope's ctor against the per-scope
// GameMgr — calling it here would crash because GameMgr.GetIns() Requires an ambient scope.
// Thread-safe (assembly-level Parallelizable(Fixtures) means many fixtures' [SetUp] race here).
public static void EnsureProcessGlobals()
{
if (_done) return;
lock (_processGlobalsGate)
{
if (_done) return;
EnsureProcessGlobalsCore();
_done = true;
}
}
private static void EnsureProcessGlobalsCore()
{
// Wizard.Data.Load: static /load/index snapshot. The ctor's CreateBackgroundId reads
// Data.Load.data._userTutorial (LoadDetail self-inits _userTutorial). Suppress VFX too.
Wizard.Data.Load = new Load { data = new LoadDetail() };
// CardParameter(CardCSVData) reads Data.Crossover.RestrictedCard for deck-limit calc;
// an empty Crossover returns the default count (no restriction). Private setter -> reflect.
typeof(Wizard.Data).GetProperty("Crossover",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public)
.SetValue(null, new Wizard.Crossover());
// CardMaster must be non-null before construction (the leader/class card looks up id 0).
// Load the M2 vanilla follower + the M3 fixed-damage spell + the M4 self-buff follower +
// the M5 summon-token spell AND the token it summons so each oracle can create + look up
// real stats. The summoned token id must be present: Skill_summon_token resolves it
// through CardMaster.GetCardParameterFromId during creation.
HeadlessCardMaster.Load(FollowerId, SpellId, BuffFollowerId, TokenSpellId, SummonedTokenId,
TargetSpellId, SelectTargetFollowerId, UnselectTargetFollowerId, DestroySpellId, DrawSpellId,
DynamicDamageSpellId);
// Master reference data (class-character list) for leader/class card resolution.
HeadlessMasterData.Install();
// The network emit path's payload builder (RealTimeNetworkAgent.CreateEmitData) reads
// Cute.Certification.Udid (RealTimeNetworkAgent.cs:1407). The Udid getter lazily decodes from
// Toolbox.SavedataManager (Certification.cs:35), which is null headless. Seed the private static
// backing field with a non-empty placeholder so the getter short-circuits before touching the
// savedata manager. The value is opaque to the engine (it's just echoed into the emit dict).
typeof(Cute.Certification)
.GetField("udid", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)
.SetValue(null, "headless-udid");
}
// Simple deterministic 40-card deck for multi-instance tests: every slot is the same vanilla
// FollowerId. Card 100011010 is loaded as part of EnsureProcessGlobals' HeadlessCardMaster.Load
// batch so SessionBattleEngine.Setup resolves each entry without re-loading. Kept a single
// shape — the multi-instance property being verified (per-session ambient isolation across
// parallel battles) is driven by distinct masterSeeds on the engines, not by deck variation.
public static long[] SampleDeck()
{
var deck = new long[40];
for (int i = 0; i < 40; i++) deck[i] = FollowerId;
return deck;
}
// Per-ambient seeder: writes the player/enemy chara ids onto the AMBIENT GameMgr's DataMgr.
// Called by TestBattleScope after the scope is entered so GameMgr.GetIns() routes to the
// per-test GameMgr, not whichever one happened to be ambient last.
public static void SeedCharaIdsOnCurrentAmbient()
{
// Player/enemy leaders (chara ids must map to a ClassCharacterMasterData in Master).
// Set the backing fields directly: the public SetPlayerCharaId() also pulls MyRotation/
// AvatarBattle info (more null statics) which the resolution path doesn't need (the
// TryGet* accessors are null-tolerant).
var dm = GameMgr.GetIns().GetDataMgr();
SetField(dm, "_playerCharaId", HeadlessMasterData.PlayerCharaId);
SetField(dm, "_enemyCharaId", HeadlessMasterData.EnemyCharaId);
}
// Per-ambient seeder: installs a no-op NetworkUserInfoData on the AMBIENT GameMgr so
// NetworkBattleManagerBase.CreateBackgroundId()'s GetNetworkUserInfoData().GetFieldId() call
// resolves (M13). Field id 1 == ForestField, a valid background.
public static void SeedNetUserOnCurrentAmbient()
{
// NetworkBattleManagerBase.CreateBackgroundId() (M13) reads
// GameMgr.GetIns().GetNetworkUserInfoData().GetFieldId() when the RecoveryManager yields no
// bg id (NullRecoveryManager.BackGroundId == -1). In production RealTimeNetworkAgent seeds
// this NetworkUserInfoData at match start; the bare construction path leaves GameMgr's
// _netUser null (no lazy init, unlike the other GodObject getters). Seed a no-op instance
// whose _selfInfo carries just "fieldId" (GetFieldId reads _selfInfo["fieldId"]); field id 1
// == ForestField, a valid background. Nothing here drives game state — it only satisfies the
// network mgr's background lookup, a background lookup the single-battle path
// (`SingleBattleMgr`) never performs.
var netUser = new NetworkUserInfoData();
netUser.SetSelfInfo(
new System.Collections.Generic.Dictionary<string, object> { ["fieldId"] = 1 },
isWatchReplayRecovery: false);
GameMgr.GetIns().SetNetworkUserInfoData(netUser);
}
// Seed each leader's starting life on a freshly-constructed mgr. The engine does this in
// BattleManagerBase.SetupInitialGameState -> InitializeClassLife (InitBaseMaxLife per leader),
// but the full SetupInitialGameState also cascades into rotation/avatar/turn-panel UI init
// that is irrelevant (and hostile) to a headless resolution test, so apply just the
// InitializeClassLife subset. Without this a leader's BaseMaxLife defaults to 0 — which reads
// as already-dead/game-over and silently blocks any card play (the M2 follower oracle never
// noticed because it only asserted leader life *unchanged*, and 0 == 0).
public const int DefaultLeaderLife = 20;
public static void InitLeaderLife(BattleManagerBase mgr, int life = DefaultLeaderLife)
{
((ClassBattleCardBase)mgr.BattlePlayer.Class).InitBaseMaxLife(life);
((ClassBattleCardBase)mgr.BattleEnemy.Class).InitBaseMaxLife(life);
}
// The PUBLIC prefab card-creation path (CardCreatorBase.CreateCard, createNullView:false) —
// used by anything the engine creates INTERNALLY (summons, token-draws, etc.), as opposed to
// the test's direct private null-view seam for hand cards — clones card-template prefabs held
// on BattleManagerBase.SBattleLoad. The real async battle load (CoLoad) builds these; the bare
// `new SingleBattleMgr(...)` construction path leaves SBattleLoad null (the M2 NRE was here).
// Seed it with non-null no-op CardTemplates: their `.gameObject` is a lazy shim no-op, and the
// shim's CloneObjectToParent + self-consistent object graph carry the rest. Nothing here
// computes game state — the token's authoritative stats come from CardCSVData, not the view.
public static void InitCardTemplates(BattleManagerBase mgr)
{
mgr.SBattleLoad = new SBattleLoad
{
UnitCardTemplate = new CardTemplate(),
SpellCardTemplate = new CardTemplate(),
FieldCardTemplate = new CardTemplate(),
};
// The created card's transform is positioned/parented under the battle's 3D scene-graph
// containers (CardCreatorBase.CreateCardTypeBuildInfo reads ins.CardHolder/ECardHolder/
// PCardPlace/Battle3DContainer). The real battle load instantiates these; seed non-null
// no-op GameObjects so the positioning resolves (no-op transforms; nothing rendered).
mgr.Battle3DContainer = new GameObject();
mgr.CardHolder = new GameObject();
mgr.ECardHolder = new GameObject();
mgr.PCardPlace = new GameObject();
mgr.ChoiceCardHolder = new GameObject();
mgr.EvolveCardHolder = new GameObject();
}
// The shared headless card-creation primitive. CardCreatorBase.CreateCardWithoutResources is
// the engine's own null-view creation path (CreateBase -> new *BattleCard(buildInfo).Setup(
// createNullView:true)); it's private, so reflect it rather than reimplement the 14-arg
// BuildInfo wiring. The public CardCreatorBase.CreateCard goes through prefab cloning.
//
// The engine's CreateCard also calls owner.SetupCardEvent(card); the raw
// CreateCardWithoutResources seam skips it, so we fold it in here. SetupCardEvent wires the
// per-card play events (BattlePlayerBase.cs:1452): for a SPELL/amulet it attaches
// OnPlay -> RemoveSpellCardFromHand and OnFinishWhenPlaySkill -> AddSpellCardToCemetery, which
// are how a non-follower leaves the hand at all (a follower's hand->field move is intrinsic to
// SetUpInplay, not event-driven). For a follower SetupCardEvent only attaches an OnEvolve hook
// that never fires on a vanilla play, so folding it in is a no-op there — making this a single
// primitive both follower and non-follower oracles can share.
public static BattleCardBase CreateHeadlessHandCard(int cardId, int index, bool isPlayer, BattleManagerBase mgr)
{
var io = mgr.CreatePlayerInnerOptionsBuilder();
var m = typeof(CardCreatorBase).GetMethod("CreateCardWithoutResources",
BindingFlags.NonPublic | BindingFlags.Static);
var card = (BattleCardBase)m.Invoke(null, new object[] { cardId, index, isPlayer, mgr, io });
BattlePlayerBase owner = isPlayer ? (BattlePlayerBase)mgr.BattlePlayer : mgr.BattleEnemy;
owner.SetupCardEvent(card);
return card;
}
// Put a follower DIRECTLY onto a player's board headless (vs as a side-effect of PlayCard),
// for setting up a target board state. Create it through the shared null-view seam, then drive
// the engine's own hand->field move: HandCardToField requires the card to be in HandCardList,
// then AddInplayCards it + removes it from hand (BattlePlayerBase.cs:2568). For a vanilla
// follower the OnAddPlayCard/StopBattleHandCard/OnSummonAfter events it fires are no-ops (no
// fanfare), so the follower lands on the board at its CardCSVData base stats. M2 proved the
// hand->field placement path resolves headless.
public static BattleCardBase PutFollowerInPlay(BattleManagerBase mgr, int cardId, int index, bool isPlayer)
{
var card = CreateHeadlessHandCard(cardId, index, isPlayer, mgr);
BattlePlayerBase owner = isPlayer ? (BattlePlayerBase)mgr.BattlePlayer : mgr.BattleEnemy;
owner.HandCardList.Add(card);
owner.HandCardToField(card);
return card;
}
// Push a known card onto a player's DECK headless (the M9 draw oracle's setup primitive). The
// bare `new SingleBattleMgr(...)` construction leaves DeckCardList non-null-but-empty (ctor at
// BattlePlayerBase.cs:1050), and a card's deck membership IS its `IsInDeck` (BattleCardBase.cs:970
// `=> SelfBattlePlayer.DeckCardList.Contains(this)`) — so no separate "in deck" flag is needed.
// Create the card through the same null-view seam hand/board cards use, then drive the engine's
// own AddToDeck (BattlePlayerBase.cs:3038): for a vanilla follower it is just DeckCardList.Add
// (HasDeckSelfSkill is false; the XorShiftRandom/IsMulliganEnd reshuffle bookkeeping short-
// circuits on the null/inactive headless RNG). The drawn card is then the engine's own deck
// object, so the oracle can assert deck->hand identity by reference, not just by id.
public static BattleCardBase SeedDeck(BattleManagerBase mgr, int cardId, int index, bool isPlayer)
{
var card = CreateHeadlessHandCard(cardId, index, isPlayer, mgr);
BattlePlayerBase owner = isPlayer ? (BattlePlayerBase)mgr.BattlePlayer : mgr.BattleEnemy;
owner.AddToDeck(card);
return card;
}
// Build a headless battle wired for AUTHORITATIVE RNG: real rolls under IsForecast (via the
// injected source on HeadlessBattleMgr) AND IsRandomDraw=true (the second gate — without it the
// random-select filters bypass the roll and pick index 0; BattleManagerBase.cs:415,
// SkillRandomSelectFilter.cs:42). Mirrors the opponent/turn/leader-life wiring every oracle does.
// Returns the constructed HeadlessBattleMgr; the caller seeds hands/decks/boards and plays.
public static HeadlessBattleMgr NewAuthoritativeBattle(IRandomSource rng)
{
EnsureProcessGlobals(); // sets IsForecast = true among other globals
BattleManagerBase.IsRandomDraw = true; // the second RNG gate (F-RNG-2)
var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator(), rng);
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
SetField(player, "_opponentBattlePlayer", enemy);
SetField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
InitLeaderLife(mgr); // a 0-life leader reads as game-over and blocks plays
InitCardTemplates(mgr); // the draw VFX touches the drawn card's view layer
return mgr;
}
// M13 emit-path read. Builds a HeadlessNetworkBattleMgr (the emitting twin of the
// HeadlessBattleMgr NewAuthoritativeBattle returns) and stands up the OnEmit capture seam: the
// engine's own RealTimeNetworkAgent.OnEmit event (RealTimeNetworkAgent.cs:1270) fires the played
// URI before both emit guards, so capturing it needs no Engine/shim edit — just an injected agent.
// Returns (mgr, emitted-URI list). The caller seeds the hand and drives mgr.OperateMgr.PlayCard.
public static (HeadlessNetworkBattleMgr mgr, System.Collections.Generic.List<NetworkBattleDefine.NetworkBattleURI> emitted)
NewNetworkEmitBattle(IRandomSource rng = null)
{
EnsureProcessGlobals(); // sets IsForecast = true among other globals
var mgr = new HeadlessNetworkBattleMgr(new HeadlessContentsCreator(), rng);
// NOTE: IsRecovery is left FALSE here (unlike the solo NewAuthoritativeBattle). The network
// emit path is gated on !IsRecovery in BOTH places: NetworkStandardBattleMgr.SendPlayCard
// (NetworkStandardBattleMgr.cs:155) and the OnSetCardComplete->SendPlayCard subscription in
// SetUpNetworkOperateEvent (NetworkBattleManagerBase.cs:927, which early-returns under
// IsRecovery). With IsRecovery=true the play would resolve state but never emit. (The solo
// NewAuthoritativeBattle uses IsRecovery=true only to collapse VFX wait delays; here the no-op
// view shims absorb the real view layer instead — see the IsForecast=false block below.)
// IsForecast MUST be false on the network emit path. BattleManagerBase.IsVirtualBattle is
// `=> IsForecast` (BattleManagerBase.cs:657), and NetworkStandardBattleMgr.SendPlayCard is gated
// on `!IsVirtualBattle` (NetworkStandardBattleMgr.cs:155) — under IsForecast=true the play
// resolves state but the emit is suppressed. EnsureInitialized leaves IsForecast=true (correct
// for the direct-ActionProcessor solo oracles, where it suppresses VFX); clear it here so the
// genuine emit fires. The cost is that VFX registration is no longer short-circuited, so the
// play exercises the real view layer — those view touches are satisfied by the no-op view shims
// (InitCardTemplates, the HandView/DetailPanel fills below). M3's damage is literal, immune to
// any play-count bump the OperateMgr path adds vs the direct path.
BattleManagerBase.IsForecast = false;
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
SetField(player, "_opponentBattlePlayer", enemy);
SetField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
InitLeaderLife(mgr); // a 0-life leader reads as game-over and blocks plays
InitCardTemplates(mgr); // play/draw VFX touches the card view layer
// The OperateMgr emit path runs SetupActionProcessorEvent (skipped by the direct-ActionProcessor
// solo oracles), which subscribes BattleMgr.DetailMgr.DetailPanelControl.UpdateCardDescriptionOnEvent
// to OnPlayComplete (BattlePlayerBase.cs:1431). DetailMgr is created in CreateManager but its
// DetailPanelControl (a UI control) is null headless. Seed the engine's own NullDetailPanelControl
// no-op so the play-complete event resolves without touching the UI.
mgr.DetailMgr.DetailPanelControl = new NullDetailPanelControl();
// Inject a headless RealTimeNetworkAgent so NetworkBattleSender's ToolboxGame.RealTimeNetworkAgent
// .* calls resolve, and subscribe OnEmit. GetUninitializedObject skips the MonoBehaviour Awake.
var agent = (RealTimeNetworkAgent)System.Runtime.Serialization.FormatterServices
.GetUninitializedObject(typeof(RealTimeNetworkAgent));
// CurrentMatchingStatus has a protected setter; seed it non-Disconnected so EmitMsgPack does not
// early-return at RealTimeNetworkAgent.cs:1272 (needed only for the best-effort payload read, Task 4;
// OnEmit fires regardless). The default on the uninitialized object is OffLine (0), which clears the
// SetCurrentMatchingStatus guards; the only side effect is a static-StringBuilder trace log, so the
// public setter runs cleanly headless. Prepared (50) is the real enum member (RealTimeNetworkAgent.cs:35).
agent.SetCurrentMatchingStatus(RealTimeNetworkAgent.MatchingStatus.Prepared);
// EmitMsgPack -> AddActionSequence (RealTimeNetworkAgent.cs:1773, fired for the PlayActions URI)
// does `_gungnir._actionSequenceNum++` and `NetworkLogger.LogInfo(...)`. On the
// GetUninitializedObject agent both are null (the real ctor builds them at :289/:301). Seed an
// uninitialized Gungnir (its ctor news a ConnectionReporter + Ticks — unneeded; AddActionSequence
// only touches the int counter) and the engine's own NetworkNullLogger no-op so the action-seq
// bookkeeping runs without crashing. Neither drives game state.
SetField(agent, "_gungnir",
System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(Gungnir)));
SetProperty(agent, "NetworkLogger", new NetworkNullLogger());
// Suppress the actual socket transmission. After OnEmit fires (RealTimeNetworkAgent.cs:1270, the
// O1 liveness signal), EmitMsgPack -> EmitMsgUriPack reaches the stockEmitMessageMgr / _manager.Socket
// network I/O (RealTimeNetworkAgent.cs:1444+/1487) — none of which exists headless. The engine's
// OWN _notEmit flag (set in recovery/replay) short-circuits EmitMsgUriPack at :1438 BEFORE any of
// that, so the emit stays genuine (OnEmit already fired through the real send path) while the
// byte-push is skipped. This is the only honest way to terminate the path headless: we are NOT
// faking OnEmit, only declining to open a socket we cannot open.
SetField(agent, "_notEmit", true);
var emitted = new System.Collections.Generic.List<NetworkBattleDefine.NetworkBattleURI>();
agent.OnEmit += uri => emitted.Add(uri);
Wizard.ToolboxGame.SetRealTimeNetworkBattle(agent);
return (mgr, emitted);
}
// M13 Task 4 best-effort: read the emit payload back out of the agent's stock sequencer. With
// _notEmit=true (NewNetworkEmitBattle terminates the path that way), EmitMsgUriPack short-circuits
// BEFORE stockEmitMessageMgr.StockData (RealTimeNetworkAgent.cs:1438 vs :1461), so the stock is
// expected to be null/empty — return null on any null/throw so the test degrades to Inconclusive
// rather than failing. Field `stockEmitMessageMgr` (:103) + `GetSequenceAllData()`
// (StockEmitMgr.cs:81, returns List<Dictionary<string,object>>) verified against the copied engine.
// Precondition: this is expected-null ONLY while NewNetworkEmitBattle sets _notEmit=true and leaves
// stockEmitMessageMgr unconstructed. If that harness setup changes, revisit — a non-null stock should
// then make the test ASSERT on the payload rather than defer to Inconclusive.
public static System.Collections.IList TryReadStockedEmitData(RealTimeNetworkAgent agent)
{
try
{
var f = typeof(RealTimeNetworkAgent).GetField("stockEmitMessageMgr",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
var stock = f?.GetValue(agent);
if (stock == null) return null;
var m = stock.GetType().GetMethod("GetSequenceAllData");
return m?.Invoke(stock, null) as System.Collections.IList;
}
catch { return null; }
}
private static void SetField(object obj, string name, object value)
{
var f = obj.GetType().GetField(name,
System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Public);
if (f == null) throw new System.InvalidOperationException(
$"{obj.GetType().Name} has no field '{name}'");
f.SetValue(obj, value);
}
// Set a property whose setter is non-public (e.g. RealTimeNetworkAgent.NetworkLogger has a
// protected setter). Walks the type hierarchy because the declaring type may be a base class.
private static void SetProperty(object obj, string name, object value)
{
var t = obj.GetType();
System.Reflection.PropertyInfo p = null;
while (t != null && p == null)
{
p = t.GetProperty(name,
System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Public);
t = t.BaseType;
}
if (p == null) throw new System.InvalidOperationException(
$"{obj.GetType().Name} has no property '{name}'");
p.SetValue(obj, value);
}
}
// Test-side replica of the engine's own StandardBattleMgrContentsCreator (the practice/solo
// init path: GameMgr.cs:244 `new SingleBattleMgr(new StandardBattleMgrContentsCreator(null, null))`).
// Authored here (not copied) so we control the seed deterministically; uses the real engine
// managers verbatim. The real StandardBattleMgrContentsCreator + SingleBattlePhaseCreator were
// cut from the M1 copy set (entry-point constructors), so we reproduce them minimally.
public sealed class HeadlessContentsCreator : IBattleMgrContentsCreator
{
public int RandomSeed => 12345; // fixed; vanilla follower has no RNG so value is irrelevant
// No-op managers (vs the practice path's file-backed SingleBattleRecoveryRecordManager):
// the ctor's FirstRecoverySetting/FirstReplaySetting dereference these, and recovery/replay
// recording is irrelevant to the M2 oracle, so use the engine's own null implementations.
public IRecoveryManager RecoveryManager { get; } = new NullRecoveryManager();
public IRecoveryRecordManager RecoveryRecordManager { get; } = new NullRecoveryRecordManager();
public IReplayRecordManager ReplayRecordManager { get; } = new NullReplayRecordManager();
public IBattleResourceMgr CreateResourceMgr() => new BattleResourceMgr();
public VfxMgr CreateVfxMgr() => new VfxMgr();
public IPhaseCreator CreatePhaseCreator(BattleManagerBase battleMgr) =>
new HeadlessPhaseCreator(battleMgr);
}
// Equivalent of the engine's SingleBattlePhaseCreator: inherits PhaseCreatorBase wholesale.
public sealed class HeadlessPhaseCreator : PhaseCreatorBase
{
public HeadlessPhaseCreator(BattleManagerBase battleMgr) : base(battleMgr) { }
}
}

View File

@@ -0,0 +1,64 @@
using NUnit.Framework;
using Wizard.Battle.View;
using Wizard.Battle.View.Vfx;
namespace SVSim.BattleEngine.Tests
{
// Regression for the Heal-triggered Skill_heal NRE diagnosed 2026-06-07 (bid 799755786270).
//
// A follower with a `when_spell_play` Heal trigger fires on a spell play and routes through
// Skill_heal.Start → ClassBattleCardBase.ApplyHealing → CreatePullHandInVfx
// → HandViewBase.HandUnfocus (HandViewBase.cs:124-131)
// The base implementation does `_handControl.SetHandState(HandControl.HandState.Unfocus)`.
// HeadlessHandViewStub.CreateHandControl returns null in headless, so `_handControl` is null
// and the base method NREs unconditionally — even when the heal amount is 0.
//
// The fix overrides HandUnfocus/HandFocus/FocusRearrangeHandHand on the stub to return
// NullVfx without touching `_handControl`. These are PURE PRESENTATION methods (visual
// ease-in/ease-out of the hand cards) — no game-state implications — so no-op'ing them
// headless is safe; the surrounding state mutations in ApplyHealing (HealLife, skill triggers)
// still run.
//
// Pattern parity with the metamorphose-NRE shim fix in ViewUiTouchStubs.cs (BattleCardView.GameObject
// lazy non-null): production Unity touches that the headless engine must no-op rather than throw.
[TestFixture]
public class HeadlessHandViewStubTests
{
[Test]
public void HandUnfocus_does_not_throw_and_returns_non_null_vfx()
{
var stub = HeadlessHandViewStub.Instance;
VfxBase vfx = null;
Assert.DoesNotThrow(() => vfx = stub.HandUnfocus(),
"HandUnfocus must no-op headlessly — the live regression (bid 799755786270) crashed " +
"Skill_heal.Start when a when_spell_play Heal trigger fired with heal:0 because the " +
"base HandUnfocus dereferences a null _handControl.");
Assert.That(vfx, Is.Not.Null, "must return a non-null Vfx (caller registers it on a sequential player).");
}
[Test]
public void HandFocus_does_not_throw_and_returns_non_null_vfx()
{
var stub = HeadlessHandViewStub.Instance;
VfxBase vfx = null;
Assert.DoesNotThrow(() => vfx = stub.HandFocus(),
"HandFocus is the sister cosmetic touch (called from CreatePullHandOutVfx on the " +
"OWNER's turn). Same null _handControl, same headless no-op required.");
Assert.That(vfx, Is.Not.Null);
}
[Test]
public void FocusRearrangeHandHand_does_not_throw_and_returns_non_null_vfx()
{
var stub = HeadlessHandViewStub.Instance;
VfxBase vfx = null;
Assert.DoesNotThrow(() => vfx = stub.FocusRearrangeHandHand(),
"FocusRearrangeHandHand reads _handControl.IsHandStateFocus() before dispatching to " +
"HandFocus or HandUnfocus; the base implementation would NRE on the read.");
Assert.That(vfx, Is.Not.Null);
}
}
}

View File

@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Runtime.Serialization;
using Wizard;
namespace SVSim.BattleEngine.Tests
{
// Builds the minimal Data.Master reference context a headless battle reads. In the client this
// comes from the /load/index master section; here we author just enough for the resolution path
// (currently: ClassCharacterList, so the leader/class card can resolve player/enemy class_id).
// Entries are constructed without their CSV ctor (private setters set via reflection).
public static class HeadlessMasterData
{
public const int PlayerCharaId = 1;
public const int EnemyCharaId = 2;
public const int PlayerClassId = 1; // ClanType -> class card clan
public const int EnemyClassId = 2;
public static void Install()
{
var master = (Master)FormatterServices.GetUninitializedObject(typeof(Master));
// The resolution path reads many Master.* collections (e.g. WhenPlayEffectKeywordMaster)
// and calls LINQ on them unguarded. Default every collection member to an empty instance
// so those touches no-op instead of NRE; then override the ones we need with content.
EnsureEmptyCollections(master);
var list = new List<ClassCharacterMasterData>
{
NewChara(PlayerCharaId, PlayerClassId),
NewChara(EnemyCharaId, EnemyClassId),
};
SetMember(master, "ClassCharacterList", list);
Data.Master = master;
}
// Initialize every List<>/array/Dictionary<> field/auto-property on the object to an empty
// non-null instance (only if currently null).
private static void EnsureEmptyCollections(object obj)
{
const BindingFlags bf = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
foreach (var f in obj.GetType().GetFields(bf))
{
if (f.GetValue(obj) != null) continue;
var empty = EmptyOf(f.FieldType);
if (empty != null) f.SetValue(obj, empty);
}
}
private static object EmptyOf(Type t)
{
if (t.IsArray) return Array.CreateInstance(t.GetElementType(), 0);
if (t.IsGenericType)
{
var def = t.GetGenericTypeDefinition();
if (def == typeof(List<>) || def == typeof(Dictionary<,>) ||
def == typeof(HashSet<>) || def == typeof(IList<>) ||
def == typeof(IDictionary<,>) || def == typeof(ICollection<>) ||
def == typeof(IEnumerable<>))
{
var concrete = def == typeof(List<>) || def == typeof(IList<>) ||
def == typeof(ICollection<>) || def == typeof(IEnumerable<>)
? typeof(List<>).MakeGenericType(t.GetGenericArguments())
: def == typeof(HashSet<>)
? typeof(HashSet<>).MakeGenericType(t.GetGenericArguments())
: typeof(Dictionary<,>).MakeGenericType(t.GetGenericArguments());
return Activator.CreateInstance(concrete);
}
}
return null;
}
private static ClassCharacterMasterData NewChara(int charaId, int classId)
{
var c = (ClassCharacterMasterData)FormatterServices.GetUninitializedObject(typeof(ClassCharacterMasterData));
SetMember(c, "chara_id", charaId);
SetMember(c, "class_id", classId);
SetMember(c, "skin_id", charaId);
SetMember(c, "is_usable", true);
return c;
}
// Set a member (auto-property backing field or field) by name, tolerating private setters.
private static void SetMember(object obj, string name, object value)
{
var t = obj.GetType();
const BindingFlags bf = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var p = t.GetProperty(name, bf);
if (p != null && p.SetMethod != null) { p.SetValue(obj, value); return; }
var f = t.GetField(name, bf)
?? t.GetField($"<{name}>k__BackingField", bf);
if (f != null) { f.SetValue(obj, value); return; }
throw new InvalidOperationException($"{t.Name} has no settable member '{name}'");
}
}
}

View File

@@ -0,0 +1,134 @@
using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M8 (death VIA COMBAT MATH): a when_play TARGETED-DAMAGE spell whose amount is >= the target
// follower's life resolves to correct authoritative state HEADLESS via the same IsForecast/
// IsRecovery + ActionProcessor + selectedCards path M6/M7 proved. M3 proved `damage` to the LEADER
// (life-delta, no death). M7 proved board-removal via UNCONDITIONAL `destroy`. M8 closes the gap
// between them: the follower dies as a CONSEQUENCE of damage -> life<=0 -> the dead-check + the same
// RemoveInplayCard/cemetery path M7 lit up — the dominant real-card removal mechanic (most "deal N
// damage" cards), reached through combat math rather than a `destroy` skill.
//
// The spell is select_count=1 (proven in M6 — it hits ONLY the selected target), so the oracle is:
// with two followers on the enemy board STRADDLING the 5 damage and the LETHAL one passed as
// `selectedCards`, the selected follower (life 2 <= 5) DIES from combat math (enemy board -1, gone,
// in CemeteryList — the M7 removal assertions, but reached via damage not `destroy`), while the
// un-selected control (life 7 > 5) is UNTOUCHED (life unchanged, still on board — the M6 routing
// assertion). The STRADDLE is what makes death-via-combat-math falsifiable: the load-bearing probe
// (swap the selection to the 6/7) makes that follower SURVIVE at 2 (7-5) and NOBODY die — proving
// the removal is gated on the SELECTED follower's life reaching <= 0 (combat math), not on
// "selected gets removed" (which would be M7's unconditional `destroy`) or a blanket wipe.
[TestFixture]
public class LethalDamageSpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
private static void SetPrivateField(object obj, string name, object value)
{
var t = obj.GetType();
var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); }
Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}");
f.SetValue(obj, value);
}
[Test]
public void Lethal_damage_spell_kills_the_selected_follower_and_chips_the_survivor()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
// Minimal opponent/turn wiring (see M2-M7 oracles): opponent refs + active turn flag. The
// spell's target resolver walks player -> opponent -> opponent's in-play followers.
SetPrivateField(player, "_opponentBattlePlayer", enemy);
SetPrivateField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
// Seed leader life so neither leader reads as a 0-life game-over state (blocks plays, M3).
HeadlessEngineEnv.InitLeaderLife(mgr);
// Put TWO vanilla followers on the ENEMY board STRADDLING the 5 damage: the SELECTED target
// has life 2 (<= 5) so it dies; the un-selected control has life 7 (> 5) and, being a
// select_count=1 spell's non-target, is untouched. (The straddle powers the load-bearing
// probe: selecting the 6/7 instead makes it survive at 2 and nobody die.)
var selected = HeadlessEngineEnv.PutFollowerInPlay(mgr, HeadlessEngineEnv.LethalTargetFollowerId, 0, isPlayer: false);
var survivor = HeadlessEngineEnv.PutFollowerInPlay(mgr, HeadlessEngineEnv.SurvivorTargetFollowerId, 1, isPlayer: false);
// Sanity: the chosen ids actually straddle the damage (one lethal, one not) at setup.
Assert.That(selected.Life, Is.LessThanOrEqualTo(HeadlessEngineEnv.LethalDamage),
"selected follower's life is not <= the spell damage (it would not die)");
Assert.That(survivor.Life, Is.GreaterThan(HeadlessEngineEnv.LethalDamage),
"survivor follower's life is not > the spell damage (it would not survive)");
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.LethalDamageSpellId);
// Place the lethal-damage spell in the active player's hand with PP to spare.
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.LethalDamageSpellId, 1, isPlayer: true, mgr);
player.HandCardList.Add(card);
player.Pp = 10;
// Pre-state snapshot.
int ppBefore = player.Pp;
int handBefore = player.HandCardList.Count;
int playerInplayBefore = player.ClassAndInPlayCardList.Count;
int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count;
int enemyCemeteryBefore = enemy.CemeteryList.Count;
int survivorLifeBefore = survivor.Life;
int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life;
// Resolve the play through the real engine, passing the chosen (lethal) target via selectedCards.
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
var ap = new ActionProcessor(pair);
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: new List<BattleCardBase> { selected }),
"ActionProcessor.PlayCard threw on a lethal targeted-damage spell");
Assert.Multiple(() =>
{
// PRIMARY M8 — death via combat math: the SELECTED follower (life <= damage) is removed
// from the enemy board and lands in the cemetery (the M7 removal dimension, reached
// through damage rather than `destroy`).
Assert.That(enemy.ClassAndInPlayCardList, Does.Not.Contain(selected),
"lethal-damaged follower still on the enemy board (death-via-damage did not remove it)");
Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore - 1),
"enemy board count not -1 (lethal damage did not commit a removal, or hit the wrong count)");
Assert.That(enemy.CemeteryList, Contains.Item(selected),
"lethal-damaged follower not in the enemy CemeteryList");
Assert.That(enemy.CemeteryList.Count, Is.EqualTo(enemyCemeteryBefore + 1),
"enemy cemetery count not +1");
// PRIMARY M8 — routing: the UN-SELECTED control (life > damage) is UNTOUCHED and stays on
// the board (the M6 routing assertion; select_count=1 hits only the selected target, so
// this proves the lethal removal was routed to the selection and is not a blanket wipe).
Assert.That(enemy.ClassAndInPlayCardList, Contains.Item(survivor),
"un-selected follower was removed (effect not routed, or a blanket wipe)");
Assert.That(survivor.Life, Is.EqualTo(survivorLifeBefore),
"un-selected follower took damage (effect not routed to the selection)");
// Leader untouched (the spell targets a follower, not the face).
Assert.That(enemy.ClassAndInPlayCardList[0].Life, Is.EqualTo(enemyLeaderLifeBefore),
"opponent leader life changed (damage hit the leader, not the selected follower)");
// Cost paid; spell leaves hand and (being a spell) does NOT occupy the board.
Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost");
Assert.That(player.HandCardList, Does.Not.Contain(card), "spell still in hand");
Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1");
Assert.That(player.ClassAndInPlayCardList, Does.Not.Contain(card), "spell wrongly placed on the board");
Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(playerInplayBefore), "player board count changed");
});
}
}
}

View File

@@ -0,0 +1,99 @@
#nullable enable
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using SVSim.BattleEngine.Ambient;
using SVSim.BattleNode.Sessions.Engine;
namespace SVSim.BattleEngine.Tests;
/// <summary>The forcing-function tests for the multi-instancing migration (Task 8). Each engine
/// instance carries its OWN <see cref="BattleAmbientContext"/> internally (SessionBattleEngine
/// constructs a per-session ctx in its field initializer and enters it on every Setup/Receive/
/// read), so two engines on two tasks must resolve independently — no shared "current mgr",
/// "current GameMgr", or "current viewer id" state. The stress test pins
/// parallel-equals-sequential to catch any residual contamination (which would manifest as a
/// life/PP/hand-count mismatch between the parallel and sequential runs).</summary>
[TestFixture, Parallelizable(ParallelScope.All)]
public class MultiInstanceEngineTests
{
[OneTimeSetUp]
public void OneTimeSetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
[Test]
public async Task TwoBattles_ResolveIndependently_OnDifferentTasks()
{
var engineA = new SessionBattleEngine();
var engineB = new SessionBattleEngine();
engineA.Setup(masterSeed: 111, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck(),
seatAClass: 1, seatBClass: 2);
engineB.Setup(masterSeed: 222, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck(),
seatAClass: 5, seatBClass: 7);
var taskA = Task.Run(() => DriveBasicTurns(engineA));
var taskB = Task.Run(() => DriveBasicTurns(engineB));
await Task.WhenAll(taskA, taskB);
// Pin the engines' post-Setup state to concrete starting values: LeaderLife=20 (InitLeaderLife's
// DefaultLeaderLife, applied by SessionBattleEngine.Setup), Pp=0 (pre-first-turn, no PP refill
// has run), HandCount=0 (Setup builds the deck/leader graph but doesn't deal an opening hand —
// mulligan/draw happens once a turn-start phase runs, which DriveBasicTurns doesn't trigger).
// Both engines must report the SAME starting state regardless of distinct masterSeeds, which is
// the cross-contamination property under test: ambient isolation means neither engine's reads
// can leak into the other's seat lookups.
Assert.That(engineA.LeaderLife(true), Is.EqualTo(20));
Assert.That(engineB.LeaderLife(true), Is.EqualTo(20));
Assert.That(engineA.Pp(true), Is.EqualTo(0));
Assert.That(engineB.Pp(true), Is.EqualTo(0));
Assert.That(engineA.HandCount(true), Is.EqualTo(0));
Assert.That(engineB.HandCount(true), Is.EqualTo(0));
}
[Test]
public async Task StressN_BaselineMatches([Values(4, 8, 16)] int n)
{
var inputs = new (int seed, long[] deckA, long[] deckB)[n];
for (int i = 0; i < n; i++)
inputs[i] = (1000 + i, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck());
// Setup AND Drive both parallelize: the residual decomp-origin static accumulators
// (Wizard.LocalLog._lastTraceLogStringBuilder etc.) and the Unity Resources shim
// cache are now thread-safe (static lock / ConcurrentDictionary), so two engines
// constructing in parallel no longer corrupts shared scratch state. The full
// construct-then-read pipeline runs concurrently per task and the result still
// pins to the sequential baseline — that is the cross-contamination property
// under test (ambient isolation + safe shared statics).
var parallel = await Task.WhenAll(inputs.Select(input => Task.Run(() =>
{
var e = new SessionBattleEngine();
e.Setup(input.seed, input.deckA, input.deckB);
DriveBasicTurns(e);
return e.LeaderLife(true);
})));
var sequential = new int[n];
for (int i = 0; i < n; i++)
{
var e = new SessionBattleEngine();
e.Setup(inputs[i].seed, inputs[i].deckA, inputs[i].deckB);
DriveBasicTurns(e);
sequential[i] = e.LeaderLife(true);
}
Assert.That(parallel, Is.EqualTo(sequential));
}
[Test]
public void GameMgr_GetIns_WithoutScope_Throws()
{
Assert.That(BattleAmbient.Current, Is.Null);
Assert.Throws<System.InvalidOperationException>(() => GameMgr.GetIns());
}
private static void DriveBasicTurns(SessionBattleEngine e)
{
_ = e.LeaderLife(true);
_ = e.Pp(true);
_ = e.HandCount(true);
}
}

View File

@@ -0,0 +1,19 @@
namespace SVSim.BattleEngine.Tests
{
// Shared base for every network-emit test fixture (M13 EmitPathReadOracleTests, the
// construction-probe's OnEmit seam test, and any M14+ network fixture to come).
//
// POST-TASK-8 (multi-instancing migration): now empty. The historical hygiene gap this class
// closed (HeadlessEngineEnv.NewNetworkEmitBattle leaving IsForecast=false + a stray injected
// agent visible to a later solo fixture) was a PROCESS-GLOBAL leak via the now-deleted
// BattleManagerBase._isForecastFallback + ToolboxGame._realTimeNetworkAgentFallback statics.
// Both fields are gone: IsForecast/RealTimeNetworkAgent live on the per-test ambient context
// (TestBattleScope's BattleAmbientContext), so scope Dispose drops them. A later fixture's
// new TestBattleScope starts a fresh ctx with IsForecast=true and a null NetworkAgent by
// default — exactly the EnsureInitialized invariant the old TearDown manually restored.
//
// Kept as a marker base class so derived fixtures don't churn; can be deleted in Task 9.
public abstract class NetworkEmitFixtureBase
{
}
}

View File

@@ -0,0 +1,41 @@
using NUnit.Framework;
using SVSim.BattleEngine.Rng;
using Wizard.BattleMgr;
namespace SVSim.BattleEngine.Tests
{
// M13 step 1 (the M2 ConstructionProbe pattern): can a NetworkBattleManagerBase-derived mgr be
// built headless at all? NetworkBattleManagerSetup constructs NetworkTouchControl(this,
// _battleCamera, _backGround) + RegisterActionManager + OperateReceive — the largest new shim
// surface since M5's prefab path. Isolate "ctor runs" before any play is driven.
[TestFixture]
public class NetworkMgrConstructionProbeTests : NetworkEmitFixtureBase
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[Test]
public void HeadlessNetworkBattleMgr_constructs_headless()
{
Assert.DoesNotThrow(() =>
{
var mgr = new HeadlessNetworkBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
Assert.That(mgr, Is.Not.Null);
});
}
[Test]
public void OnEmit_capture_seam_is_wired_via_injected_agent()
{
var (mgr, emitted) = HeadlessEngineEnv.NewNetworkEmitBattle();
_scope.Ctx.Mgr = mgr;
Assert.That(mgr, Is.Not.Null);
Assert.That(Wizard.ToolboxGame.RealTimeNetworkAgent, Is.Not.Null,
"agent must be injected so NetworkBattleSender's ToolboxGame.RealTimeNetworkAgent.* calls resolve");
Assert.That(emitted, Is.Empty, "no emit yet — only the seam is wired");
}
}
}

View File

@@ -0,0 +1,84 @@
using System.Linq;
using NUnit.Framework;
using SVSim.BattleEngine.Rng;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M12: the first card whose outcome is a GENUINE RNG roll. The M9 draw spell over a 3-card deck with
// IsRandomDraw=true selects via SkillRandomSelectFilter -> GetIns().StableRandom(poolCount), which
// HeadlessBattleMgr routes to the injected ScriptedRandomSource. The oracle asserts the engine drew
// EXACTLY the card the scripted roll selects, and (load-bearing) that the pick TRACKS the script:
// a different scripted unit draws a different card. This is the multi-outcome roll M9's one-card pool
// deliberately neutralized — it requires the F2 decoupling (real rolls under IsForecast) AND the
// IsRandomDraw=true second gate, both delivered by NewAuthoritativeBattle.
[TestFixture]
public class RandomDrawOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown]
public void ResetRandomDrawGate()
{
// NewAuthoritativeBattle sets the process-global BattleManagerBase.IsRandomDraw = true; reset it
// so this fixture doesn't leak that state into later-running fixtures (which expect the default
// false / top-of-deck draw behavior). Prevents order-dependent flakes as more RNG oracles land.
// (Now an ambient write inside the scope; harmless either way.)
BattleManagerBase.IsRandomDraw = false;
_scope?.Dispose();
_scope = null;
}
// Draw with a single scripted unit; return (drawnCardId, deckCountAfter). The deck is seeded with
// three distinguishable cards at indices 2,3,4 -> Index-order positions 0,1,2 map to
// RngDeckCardA/B/C. The draw makes one StableRandom(3) call -> index = floor(3*unit).
private (int drawnId, int deckAfter) DrawWith(double unit)
{
var mgr = HeadlessEngineEnv.NewAuthoritativeBattle(new ScriptedRandomSource(new[] { unit }));
_scope.Ctx.Mgr = mgr;
var player = mgr.BattlePlayer;
HeadlessEngineEnv.SeedDeck(mgr, HeadlessEngineEnv.RngDeckCardA, index: 2, isPlayer: true);
HeadlessEngineEnv.SeedDeck(mgr, HeadlessEngineEnv.RngDeckCardB, index: 3, isPlayer: true);
HeadlessEngineEnv.SeedDeck(mgr, HeadlessEngineEnv.RngDeckCardC, index: 4, isPlayer: true);
var spell = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.RngDrawSpellId, 1, isPlayer: true, mgr);
player.HandCardList.Add(spell);
player.Pp = 10;
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
var ap = new ActionProcessor(pair);
Assert.DoesNotThrow(() => ap.PlayCard(spell, selectedCards: null), "PlayCard threw on the random draw");
// The drawn card is the new hand entry that is not the spell.
var drawn = player.HandCardList.Single(c => c.CardId != HeadlessEngineEnv.RngDrawSpellId);
return (drawn.CardId, player.DeckCardList.Count);
}
[Test]
public void Random_draw_picks_the_scripted_card()
{
// unit 0.5 -> floor(3*0.5)=1 -> Index-order position 1 -> RngDeckCardB.
var (drawnId, deckAfter) = DrawWith(0.5);
Assert.Multiple(() =>
{
Assert.That(drawnId, Is.EqualTo(HeadlessEngineEnv.RngDeckCardB),
"scripted roll 0.5 should draw the middle (Index-order position 1) deck card");
Assert.That(deckAfter, Is.EqualTo(2), "deck should be 3 -> 2 after drawing one");
});
}
[Test]
public void Random_draw_pick_tracks_the_scripted_roll()
{
// Load-bearing: varying the scripted unit must move the pick across all three positions.
// floor(3*0.0)=0 -> A ; floor(3*0.5)=1 -> B ; floor(3*0.9)=2 -> C.
Assert.That(DrawWith(0.0).drawnId, Is.EqualTo(HeadlessEngineEnv.RngDeckCardA), "0.0 -> position 0");
Assert.That(DrawWith(0.5).drawnId, Is.EqualTo(HeadlessEngineEnv.RngDeckCardB), "0.5 -> position 1");
Assert.That(DrawWith(0.9).drawnId, Is.EqualTo(HeadlessEngineEnv.RngDeckCardC), "0.9 -> position 2");
}
}
}

View File

@@ -0,0 +1,105 @@
using System;
using NUnit.Framework;
using SVSim.BattleEngine.Rng;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
[TestFixture]
public class RngSeamTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
// RandomSourceBridge.Range must mirror the engine's exact roll arithmetic:
// BattleManagerBase.StableRandom does `(int)Math.Floor((double)val * unit)`.
[Test]
public void Bridge_Range_mirrors_engine_floor_arithmetic()
{
Assert.That(RandomSourceBridge.Range(7, 0.0), Is.EqualTo(0)); // floor(7*0) = 0
Assert.That(RandomSourceBridge.Range(7, 0.999), Is.EqualTo(6)); // floor(6.993) = 6 (never == val)
Assert.That(RandomSourceBridge.Range(3, 0.5), Is.EqualTo(1)); // floor(1.5) = 1 (middle of 3)
Assert.That(RandomSourceBridge.Range(1, 0.5), Is.EqualTo(0)); // floor(0.5) = 0
}
// SeededRandomSource(seed) must reproduce the engine's own generators EXACTLY: BattleManagerBase
// seeds both _stableRandom and _stableRandomOnlySelf as `new System.Random(RandomSeed)`
// (BattleManagerBase.cs:721-722). NextUnit() == synced.NextDouble(); NextSelf(max) == self.Next(max).
[Test]
public void SeededSource_reproduces_two_System_Random_streams()
{
const int seed = 12345;
var src = new SeededRandomSource(seed);
var refSynced = new System.Random(seed); // mirrors _stableRandom
var refSelf = new System.Random(seed); // mirrors _stableRandomOnlySelf (separate stream)
for (int i = 0; i < 8; i++)
Assert.That(src.NextUnit(), Is.EqualTo(refSynced.NextDouble()), $"NextUnit drift at {i}");
for (int i = 0; i < 8; i++)
Assert.That(src.NextSelf(100), Is.EqualTo(refSelf.Next(100)), $"NextSelf drift at {i}");
}
// ScriptedRandomSource feeds a known sequence (the oracle's control + the Phase-3 replay seam).
// It MUST throw on overrun, not wrap: an unexpected extra roll should fail loudly so a test
// surfaces a miscount of engine RNG calls rather than silently reusing a value.
[Test]
public void ScriptedSource_returns_sequence_then_throws_on_overrun()
{
var src = new ScriptedRandomSource(new[] { 0.1, 0.5 }, new[] { 3 });
Assert.That(src.NextUnit(), Is.EqualTo(0.1));
Assert.That(src.NextUnit(), Is.EqualTo(0.5));
Assert.That(() => src.NextUnit(), Throws.InvalidOperationException, "should throw on unit overrun");
Assert.That(src.NextSelf(99), Is.EqualTo(3));
Assert.That(() => src.NextSelf(99), Throws.InvalidOperationException, "should throw on self overrun");
}
// The decoupling (F2): the override must roll REAL values even though IsForecast == true (which
// forces the un-overridden engine methods to return 0). A ScriptedRandomSource proves the value
// came from the injected source, not the engine's zeroing.
[Test]
public void Override_rolls_real_values_under_IsForecast()
{
BattleManagerBase.IsForecast = true; // would zero the un-overridden engine RNG
// 3 units; with RandomSourceBridge.Range(val, unit) = floor(val*unit):
// StableRandom(7) with 0.5 -> floor(3.5) = 3
// StableRandomDouble() -> 0.25
// StableRandomOnlySelf(10) -> scripted self pick 4
var src = new ScriptedRandomSource(new[] { 0.5, 0.25 }, new[] { 4 });
var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator(), src);
_scope.Ctx.Mgr = mgr;
Assert.That(mgr.StableRandom(7), Is.EqualTo(3), "StableRandom did not use the injected source");
Assert.That(mgr.randomResult, Is.EqualTo(0.5), "StableRandom must set randomResult to the rolled unit");
Assert.That(mgr.StableRandomDouble(), Is.EqualTo(0.25), "StableRandomDouble did not use the injected source");
Assert.That(mgr.randomResult, Is.EqualTo(0.25), "StableRandomDouble must set randomResult");
Assert.That(mgr.StableRandomOnlySelf(10), Is.EqualTo(4), "StableRandomOnlySelf did not use the injected source");
}
// Parity: with the DEFAULT (seeded) source, HeadlessBattleMgr.StableRandom must equal what the
// verbatim engine would compute — floor(val * new System.Random(seed).NextDouble()) — pinning the
// re-authored RandomSourceBridge arithmetic to the engine's own formula+generator. (The default
// source seeds from HeadlessContentsCreator.RandomSeed == 12345.)
[Test]
public void Default_source_matches_engine_generator_and_formula()
{
BattleManagerBase.IsForecast = true;
var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator()); // default SeededRandomSource(12345)
_scope.Ctx.Mgr = mgr;
var reference = new System.Random(12345);
for (int i = 0; i < 10; i++)
{
int expected = (int)System.Math.Floor(7 * reference.NextDouble());
Assert.That(mgr.StableRandom(7), Is.EqualTo(expected), $"parity drift at roll {i}");
}
}
}
}

View File

@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<!-- Match the engine: decompiled types are not nullable-clean and use explicit usings. -->
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<!-- Pinned to 12.0 (net8.0 default) to match SVSim.BattleEngine; see the rationale there
(the vendored decompiled engine breaks under C# 14's 'field' contextual keyword). -->
<LangVersion>12.0</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SVSim.BattleEngine\SVSim.BattleEngine.csproj" />
<ProjectReference Include="..\SVSim.BattleNode\SVSim.BattleNode.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Captured PvP battle (both clients) replayed through the engine in the N1 shadow test. -->
<None Include="Fixtures\**\*.ndjson" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<!-- The loader's card-master dump (serialized CardCSVData objects). The headless fixture
reflects these into CardMaster so the resolution path can look up real card stats. -->
<Content Include="..\SVSim.Bootstrap\Data\cards.json" Link="Data\cards.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using SVSim.BattleNode.Protocol;
namespace SVSim.BattleEngine.Tests.SessionEngine
{
internal sealed record CapturedFrame(DateTime Ts, string Direction, string Uri, MsgEnvelope Env, string RawBody);
/// <summary>Parses a battle_test ndjson capture into MsgEnvelopes the engine can ingest.
///
/// Capture quirk (verified against data_dumps/captures/battle_test): the authoritative URI lives at
/// the TOP LEVEL for SEND frames (the body omits uri/viewerId/uuid and carries only the play
/// payload) and in the BODY for RECEIVE frames (top-level uri is null). We resolve uri as
/// top ?? body, then normalize the body into a full envelope (injecting the fields a send-frame body
/// lacks) so MsgEnvelope.FromJson — which requires uri/viewerId/uuid — succeeds for both.</summary>
internal static class CaptureReplay
{
public static IReadOnlyList<CapturedFrame> Load(string fixtureFileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", fixtureFileName);
var frames = new List<CapturedFrame>();
foreach (var line in File.ReadLines(path))
{
if (string.IsNullOrWhiteSpace(line)) continue;
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
var direction = root.TryGetProperty("direction", out var dEl) ? dEl.GetString() ?? "" : "";
var ts = root.TryGetProperty("ts", out var tsEl) && tsEl.ValueKind == JsonValueKind.String
? DateTime.Parse(tsEl.GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)
: default;
if (!root.TryGetProperty("body", out var bodyEl) || bodyEl.ValueKind != JsonValueKind.Object)
continue;
string uri =
root.TryGetProperty("uri", out var tu) && tu.ValueKind == JsonValueKind.String
? tu.GetString()!
: bodyEl.TryGetProperty("uri", out var bu) && bu.ValueKind == JsonValueKind.String
? bu.GetString()!
: "None";
// Normalize: send-frame bodies are bare payloads (no envelope fields). Inject the keys
// FromJson requires; set the resolved uri.
var obj = JsonNode.Parse(bodyEl.GetRawText())!.AsObject();
obj["uri"] = uri;
if (!obj.ContainsKey("viewerId")) obj["viewerId"] = 0L;
if (!obj.ContainsKey("uuid")) obj["uuid"] = "";
var normalized = obj.ToJsonString();
MsgEnvelope env;
try { env = MsgEnvelope.FromJson(normalized); }
catch { continue; } // out-of-model / unparseable line
frames.Add(new CapturedFrame(ts, direction, uri, env, normalized));
}
return frames;
}
/// <summary>Both clients' SENT frames interleaved in capture (ts) order, each tagged with its
/// seat: cl1 == seat A == player (true), cl2 == seat B == opponent (false). This is the node's
/// both-clients-sends ingest order — the same ts ordering the N1 shadow-replay test uses, here
/// extended to merge both sides' sends rather than replaying one client's full receive stream.</summary>
public static IEnumerable<(MsgEnvelope Env, bool Seat)> InterleavedSends(
IReadOnlyList<CapturedFrame> cl1, IReadOnlyList<CapturedFrame> cl2)
{
return cl1.Where(f => f.Direction == "send").Select(f => (f, Seat: true))
.Concat(cl2.Where(f => f.Direction == "send").Select(f => (f, Seat: false)))
.OrderBy(x => x.f.Ts)
.Select(x => (x.f.Env, x.Seat));
}
/// <summary>The selfDeck idx-&gt;cardId order from the Matched frame (the order the node also
/// computed and handed the client). This is the deck the engine seats for that side.</summary>
public static IReadOnlyList<long> SelfDeckFrom(IEnumerable<CapturedFrame> frames)
{
var matched = frames.FirstOrDefault(f => f.Uri == nameof(NetworkBattleUri.Matched));
if (matched is null) return Array.Empty<long>();
using var doc = JsonDocument.Parse(matched.RawBody);
if (!doc.RootElement.TryGetProperty("selfDeck", out var deck)) return Array.Empty<long>();
return deck.EnumerateArray()
.OrderBy(e => e.GetProperty("idx").GetInt32())
.Select(e => e.GetProperty("cardId").GetInt64())
.ToList();
}
/// <summary>The per-battle master seed the capture carries (Matched.selfInfo.seed) — the seed the
/// node generated and both clients used (F-N-5). Falls back to 0 if absent.</summary>
public static int SeedFrom(IEnumerable<CapturedFrame> frames)
{
var matched = frames.FirstOrDefault(f => f.Uri == nameof(NetworkBattleUri.Matched));
if (matched is null) return 0;
using var doc = JsonDocument.Parse(matched.RawBody);
if (doc.RootElement.TryGetProperty("selfInfo", out var si)
&& si.TryGetProperty("seed", out var seed)
&& seed.TryGetInt32(out var v))
return v;
return 0;
}
}
}

View File

@@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using NUnit.Framework;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions.Engine;
namespace SVSim.BattleEngine.Tests.SessionEngine
{
/// <summary>
/// PHASE 4 — DECISIVE VERIFICATION (TEST-ONLY, no production fix, no Engine/*.cs edits).
///
/// QUESTION: does feeding the headless shadow engine the FULL client inputs (server-authored
/// Deal/Swap/Ready setup frames for BOTH seats + the real per-seat <c>idxChangeSeed</c>) make its
/// recovery-mode draw recompute faithful, so the "Target card was not found in hand cards"
/// divergences vanish?
///
/// This builds the explicit 2x2 {setup-frames ingested: yes/no} x {real seed: yes/no} divergence
/// table over the SAME fresh battle (907324319325, battle_test_fresh_cl1/cl2.ndjson), and — at the
/// FIRST remaining divergence — dumps the engine's hand indices/ids vs the wire's <c>playIdx</c>.
///
/// SEEDING MECHANISM (clean, both seats): the seat-B <c>Ready</c> ingest throws an NRE headless (the
/// recovery deal path isn't headless-clean for the opponent seat), so the wire <c>Ready</c> cannot be
/// relied on to seat seat B's XorShift. To inject the real seed FAITHFULLY for BOTH seats without
/// depending on the throwing Ready, we call the test seam <see cref="SessionBattleEngine"/>.
/// <c>DebugSeedIdxChange(self, oppo)</c> (-> <c>BattleManagerBase.CreateXorShift</c>) BEFORE the
/// mulligan-end frame, with the real per-seat seeds (seat A = cl1's Ready idxChangeSeed = 1430655717,
/// seat B = cl2's = 661650374). We ASSERT both <c>SelfXorShiftActive</c> and <c>OppoXorShiftActive</c>
/// are true after.
///
/// SETUP-FRAME INGEST: identical mechanism to <see cref="CaptureReplayReshuffleRootCauseTests"/> — a
/// single <c>Deal</c> (cl1's receive Deal seats BOTH hands), each seat's <c>Swap</c> (its mulligan),
/// each seat's <c>Ready</c> (mulligan-end). The {no-setup-frames} row SKIPS Deal/Swap/Ready entirely:
/// the engine's autonomous Setup hand stands, and we replay only the plays.
/// </summary>
[TestFixture]
[NonParallelizable]
public class CaptureReplayFullInputDivergenceExperimentTests
{
// Real per-seat idxChangeSeed carried by each client's Ready frame (given in the experiment brief;
// re-confirmed below against the captures).
private const int SeatASeed = 1430655717; // cl1 / seat A / player
private const int SeatBSeed = 661650374; // cl2 / seat B / opponent
private static readonly HashSet<string> SkipUris = new()
{
nameof(NetworkBattleUri.Echo),
nameof(NetworkBattleUri.ChatStamp),
nameof(NetworkBattleUri.Gungnir),
};
private static readonly HashSet<string> MulliganUris = new()
{
nameof(NetworkBattleUri.Deal),
nameof(NetworkBattleUri.Swap),
nameof(NetworkBattleUri.Ready),
};
private sealed record HandDump(string Seat, int PlayIdx, string Uri, string Reason,
IReadOnlyList<(int Index, int CardId)> SelfHand,
IReadOnlyList<(int Index, int CardId)> OppoHand,
bool PlayIdxInSelfHand, bool PlayIdxInOppoHand);
private sealed record Cell(
bool SetupFrames, bool RealSeed,
int Divergences, bool SelfXorActive, bool OppoXorActive,
HandDump? FirstNotFoundDump);
private static int ReadPlayIdx(string rawBody)
{
using var doc = JsonDocument.Parse(rawBody);
return doc.RootElement.TryGetProperty("playIdx", out var p) && p.TryGetInt32(out var v) ? v : -1;
}
// Snapshot a seat's hand as (engine Index, CardId) pairs. Reads through the SessionBattleEngine
// oracle accessors (HandCount/HandCardIndex/HandCardId).
private static List<(int, int)> HandSnapshot(SessionBattleEngine engine, bool seat)
{
var list = new List<(int, int)>();
int n = engine.HandCount(seat);
for (int i = 0; i < n; i++)
list.Add((engine.HandCardIndex(seat, i), engine.HandCardId(seat, i)));
return list;
}
private static Cell Run(bool setupFrames, bool realSeed)
{
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
var deckA = CaptureReplay.SelfDeckFrom(cl1);
var deckB = CaptureReplay.SelfDeckFrom(cl2);
Assert.That(deckA, Is.Not.Empty);
Assert.That(deckB, Is.Not.Empty);
var engine = new SessionBattleEngine();
engine.Setup(masterSeed: CaptureReplay.SeedFrom(cl1), seatADeck: deckA, seatBDeck: deckB);
Assert.That(engine.IsReady, Is.True);
// Inject the real per-seat seed BEFORE mulligan-end (Ready). Clean both-seat activation via the
// CreateXorShift seam, sidestepping the seat-B Ready NRE.
if (realSeed)
engine.DebugSeedIdxChange(SeatASeed, SeatBSeed);
int divergences = 0;
HandDump? firstNotFound = null;
void Ingest(MsgEnvelope env, bool seat, string uri, string rawBody)
{
var r = engine.Receive(env, isPlayerSeat: seat);
if (!r.Diverged) return;
divergences++;
if (firstNotFound is null && (r.RejectReason ?? "").Contains("not found in hand"))
{
int playIdx = ReadPlayIdx(rawBody);
var self = HandSnapshot(engine, seat);
var oppo = HandSnapshot(engine, !seat);
firstNotFound = new HandDump(
seat ? "A" : "B", playIdx, uri, Trim(r.RejectReason),
self, oppo,
self.Any(h => h.Item1 == playIdx), oppo.Any(h => h.Item1 == playIdx));
}
}
CapturedFrame Receive(IReadOnlyList<CapturedFrame> frames, string uri) =>
frames.First(f => f.Direction == "receive" && f.Uri == uri);
// --- Phase 1: setup frames (optional) ---------------------------------------------------------
if (setupFrames)
{
var deal = Receive(cl1, nameof(NetworkBattleUri.Deal));
Ingest(deal.Env, seat: true, nameof(NetworkBattleUri.Deal), deal.RawBody);
foreach (var (frames, seat) in new[] { (cl1, true), (cl2, false) })
{
var swap = Receive(frames, nameof(NetworkBattleUri.Swap));
Ingest(swap.Env, seat, nameof(NetworkBattleUri.Swap), swap.RawBody);
var ready = Receive(frames, nameof(NetworkBattleUri.Ready));
Ingest(ready.Env, seat, nameof(NetworkBattleUri.Ready), ready.RawBody);
}
}
bool selfActive = engine.SelfXorShiftActive;
bool oppoActive = engine.OppoXorShiftActive;
// Snapshot the engine's post-setup hands (after Deal/Swap/Ready) for the full-inputs cell, so the
// report can compare the engine's mulligan-resolved hand against the wire's Swap/Ready move list.
if (setupFrames && realSeed)
{
TestContext.WriteLine(" [post-setup] engine SELF (seat A) hand: " +
string.Join(" ", HandSnapshot(engine, true).Select(h => $"(idx={h.Item1},cid={h.Item2})")));
TestContext.WriteLine(" [post-setup] engine OPPO (seat B) hand: " +
string.Join(" ", HandSnapshot(engine, false).Select(h => $"(idx={h.Item1},cid={h.Item2})")));
}
// --- Phase 2: replay both clients' interleaved SENDS (the plays) ------------------------------
var sends = SendsWithRawBody(cl1, cl2)
.Where(x => !SkipUris.Contains(x.Frame.Uri))
.ToList();
foreach (var x in sends)
Ingest(x.Frame.Env, x.Seat, x.Frame.Uri, x.Frame.RawBody);
return new Cell(setupFrames, realSeed, divergences, selfActive, oppoActive, firstNotFound);
}
private static IEnumerable<(CapturedFrame Frame, bool Seat)> SendsWithRawBody(
IReadOnlyList<CapturedFrame> cl1, IReadOnlyList<CapturedFrame> cl2)
{
return cl1.Where(f => f.Direction == "send").Select(f => (f, Seat: true))
.Concat(cl2.Where(f => f.Direction == "send").Select(f => (f, Seat: false)))
.OrderBy(x => x.f.Ts)
.Select(x => (x.f, x.Seat));
}
private static string Trim(string? s) => (s ?? "").Split(" @ ")[0];
[Test]
public void Full_input_2x2_divergence_table_and_first_remaining_divergence_dump()
{
// Confirm the brief's per-seat seeds match the captures' Ready frames before relying on them.
ConfirmReadySeeds();
var cells = new[]
{
Run(setupFrames: false, realSeed: false), // baseline-ish: autonomous Setup hand, seed -1
Run(setupFrames: false, realSeed: true),
Run(setupFrames: true, realSeed: false),
Run(setupFrames: true, realSeed: true), // FULL INPUTS
};
TestContext.WriteLine("=== 2x2 DIVERGENCE TABLE (setup-frames x real-seed) ===");
TestContext.WriteLine("setupFrames | realSeed | divergences | selfXor | oppoXor");
foreach (var c in cells)
TestContext.WriteLine(
$" {(c.SetupFrames ? "YES" : "no ")} | {(c.RealSeed ? "YES" : "no ")} | {c.Divergences,2} | {c.SelfXorActive,-5} | {c.OppoXorActive,-5}");
var full = cells.Single(c => c.SetupFrames && c.RealSeed);
TestContext.WriteLine("");
TestContext.WriteLine($"FULL-INPUTS cell: setupFrames=YES realSeed=YES -> divergences={full.Divergences} " +
$"selfXorActive={full.SelfXorActive} oppoXorActive={full.OppoXorActive}");
if (full.FirstNotFoundDump is { } d)
{
TestContext.WriteLine("");
TestContext.WriteLine("=== FIRST 'not found in hand' DIVERGENCE (full-inputs cell) ===");
TestContext.WriteLine($" seat={d.Seat} uri={d.Uri} wire playIdx={d.PlayIdx} reason={d.Reason}");
TestContext.WriteLine($" playIdx in self hand? {d.PlayIdxInSelfHand} in oppo hand? {d.PlayIdxInOppoHand}");
TestContext.WriteLine($" engine SELF (seat {d.Seat}) hand [{d.SelfHand.Count}]: " +
string.Join(" ", d.SelfHand.Select(h => $"(idx={h.Index},cid={h.CardId})")));
TestContext.WriteLine($" engine OPPO hand [{d.OppoHand.Count}]: " +
string.Join(" ", d.OppoHand.Select(h => $"(idx={h.Index},cid={h.CardId})")));
}
else
{
TestContext.WriteLine("");
TestContext.WriteLine("FULL-INPUTS cell produced NO 'not found in hand' divergence.");
}
// EVIDENCE ASSERTIONS (pin the experiment's reproducibility, not a desired fix outcome):
Assert.Multiple(() =>
{
// The seed seam activates BOTH seats' XorShift in every realSeed cell.
foreach (var c in cells.Where(c => c.RealSeed))
{
Assert.That(c.SelfXorActive, Is.True,
$"realSeed cell (setup={c.SetupFrames}) must activate self XorShift");
Assert.That(c.OppoXorActive, Is.True,
$"realSeed cell (setup={c.SetupFrames}) must activate oppo XorShift");
}
// With NO seed seam AND NO setup frames (the live shadow's effective state — never
// ingests the seed-bearing Ready), BOTH seats' XorShift stay inactive.
var bare = cells.Single(c => !c.RealSeed && !c.SetupFrames);
Assert.That(bare.SelfXorActive, Is.False, "no-seed/no-setup leaves self XorShift inactive");
Assert.That(bare.OppoXorActive, Is.False, "no-seed/no-setup leaves oppo XorShift inactive");
// With setup frames but no seam, the seat-A Ready frame's own idxChangeSeed activates the
// SELF XorShift (seat B's Ready NREs before it can seat oppo) — so self is active, oppo isn't.
var setupNoSeam = cells.Single(c => !c.RealSeed && c.SetupFrames);
Assert.That(setupNoSeam.SelfXorActive, Is.True,
"setup-frames cell: seat-A Ready idxChangeSeed activates self XorShift");
Assert.That(setupNoSeam.OppoXorActive, Is.False,
"setup-frames cell: seat-B Ready NREs before seating oppo XorShift");
// THE DECISIVE FINDING: full inputs (setup frames + real seed, both seats' XorShift active)
// do NOT eliminate the divergences — they stay at the 14 baseline.
var full2 = cells.Single(c => c.SetupFrames && c.RealSeed);
Assert.That(full2.SelfXorActive && full2.OppoXorActive, Is.True,
"full-inputs cell has both seats' XorShift active");
Assert.That(full2.Divergences, Is.GreaterThan(0),
"REFUTED: full inputs do NOT make the recovery recompute faithful — divergences remain");
});
}
// Re-confirm the brief's per-seat seeds against the captured Ready frames (fail loudly if the
// fixtures ever drift from the assumed seeds).
private static void ConfirmReadySeeds()
{
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
int a = ReadReadySeed(cl1);
int b = ReadReadySeed(cl2);
TestContext.WriteLine($"Confirmed Ready idxChangeSeed: cl1(seatA)={a} cl2(seatB)={b}");
Assert.That(a, Is.EqualTo(SeatASeed), "cl1 Ready idxChangeSeed must equal the brief's seat-A seed");
Assert.That(b, Is.EqualTo(SeatBSeed), "cl2 Ready idxChangeSeed must equal the brief's seat-B seed");
}
private static int ReadReadySeed(IReadOnlyList<CapturedFrame> frames)
{
var ready = frames.First(f => f.Direction == "receive" && f.Uri == nameof(NetworkBattleUri.Ready));
var obj = JsonNode.Parse(ready.RawBody)!.AsObject();
return (int)obj["idxChangeSeed"]!;
}
}
}

View File

@@ -0,0 +1,288 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using NUnit.Framework;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions.Engine;
namespace SVSim.BattleEngine.Tests.SessionEngine
{
/// <summary>
/// PHASE 4 — DRAW-RECOMPUTE ROOT-CAUSE VALIDATION (TEST-ONLY; no production fix; no Engine/*.cs edits).
///
/// HYPOTHESIS (from the experiment brief): the shadow diverges ("Target card was not found in hand
/// cards", post-mulligan) because the per-turn network DRAW is a SEEDED-RANDOM pick from the deck via
/// <c>mgr.StableRandom(...)</c> (SkillRandomSelectFilter.Filtering:49/58), gated by the process-global
/// <c>BattleManagerBase.IsRandomDraw</c> — which the real match-load sets true via
/// <c>StartOpening → SetupInitialGameState(areCardsRandomlyDrawn:true)</c> (BattleManagerBase.cs:1098/1110).
/// The headless <see cref="SessionBattleEngine"/>.Setup never runs SetupInitialGameState, so IsRandomDraw
/// stays FALSE and the shadow draws TOP-OF-DECK while the clients draw seeded-random → mismatch.
/// AND the shared <c>_stableRandom</c> stream must be advanced by the wire <c>spin</c> pre-roll the Ready
/// frame carries (spin=243), which <c>OperateReceive.StartOperate:80-83</c> applies but the shadow never
/// ingests — so without it the stream is offset.
///
/// ISOLATION MATRIX (this is the report's headline): setup frames + real seed are held CONSTANT (the
/// faithful baseline the prior FullInput experiment pinned at 14); the two NEW variables are toggled:
/// • {IsRandomDraw=false, no spin} = baseline (top-of-deck draws; the live shadow's effective state)
/// • {IsRandomDraw=true, no spin} = random-draw active but stream MIS-aligned (expect WORSE)
/// • {IsRandomDraw=true, +spin} = random-draw active AND stream aligned (the hypothesised fix)
///
/// SPIN APPLICATION: spin=243 appears on the Ready frame in BOTH captures (each client applies its own
/// once). Our shadow shares ONE <c>_stableRandom</c> across both seats (seated as both players), and a
/// single client's stream sits 243 draws in after ITS Ready — so we apply spin=243 ONCE, after the
/// Deal/Swap/Ready setup frames and before the plays, exactly where the real client's StartOperate would.
/// (A scan of both fixtures confirms Ready is the ONLY frame carrying a non-zero spin.)
/// </summary>
[TestFixture]
[NonParallelizable]
public class CaptureReplayRandomDrawSpinRootCauseTests
{
private const int SeatASeed = 1430655717; // cl1 / seat A / player (Ready idxChangeSeed)
private const int SeatBSeed = 661650374; // cl2 / seat B / opponent
private const int WireSpin = 243; // both captures' Ready frame spin
private static readonly HashSet<string> SkipUris = new()
{
nameof(NetworkBattleUri.Echo),
nameof(NetworkBattleUri.ChatStamp),
nameof(NetworkBattleUri.Gungnir),
};
private sealed record HandDump(string Seat, int PlayIdx, string Uri, string Reason,
int StableRandomCount,
IReadOnlyList<(int Index, int CardId)> SelfHand,
IReadOnlyList<(int Index, int CardId)> OppoHand,
bool PlayIdxInSelfHand, bool PlayIdxInOppoHand);
private sealed record Cell(bool RandomDraw, bool Spin, int Divergences, HandDump? FirstNotFound);
private static int ReadPlayIdx(string rawBody)
{
using var doc = JsonDocument.Parse(rawBody);
return doc.RootElement.TryGetProperty("playIdx", out var p) && p.TryGetInt32(out var v) ? v : -1;
}
private static List<(int, int)> HandSnapshot(SessionBattleEngine engine, bool seat)
{
var list = new List<(int, int)>();
int n = engine.HandCount(seat);
for (int i = 0; i < n; i++)
list.Add((engine.HandCardIndex(seat, i), engine.HandCardId(seat, i)));
return list;
}
private static CapturedFrame Receive(IReadOnlyList<CapturedFrame> frames, string uri) =>
frames.First(f => f.Direction == "receive" && f.Uri == uri);
private static Cell Run(bool randomDraw, bool spin)
{
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
var deckA = CaptureReplay.SelfDeckFrom(cl1);
var deckB = CaptureReplay.SelfDeckFrom(cl2);
Assert.That(deckA, Is.Not.Empty);
Assert.That(deckB, Is.Not.Empty);
var engine = new SessionBattleEngine();
engine.Setup(masterSeed: CaptureReplay.SeedFrom(cl1), seatADeck: deckA, seatBDeck: deckB);
Assert.That(engine.IsReady, Is.True);
// CONSTANT across all cells: faithful seed seam (both seats' XorShift active), sidestepping the
// seat-B Ready NRE — identical to the FullInput experiment's full-inputs cell.
engine.DebugSeedIdxChange(SeatASeed, SeatBSeed);
// NEW VARIABLE 1: the IsRandomDraw gate. Set BEFORE any draw (deal is the first draw).
engine.DebugSetRandomDraw(randomDraw);
int divergences = 0;
HandDump? firstNotFound = null;
void Ingest(MsgEnvelope env, bool seat, string uri, string rawBody)
{
var r = engine.Receive(env, isPlayerSeat: seat);
if (!r.Diverged) return;
divergences++;
if (firstNotFound is null && (r.RejectReason ?? "").Contains("not found in hand"))
{
var self = HandSnapshot(engine, seat);
var oppo = HandSnapshot(engine, !seat);
int playIdx = ReadPlayIdx(rawBody);
firstNotFound = new HandDump(
seat ? "A" : "B", playIdx, uri, Trim(r.RejectReason),
engine.DebugStableRandomCount, self, oppo,
self.Any(h => h.Item1 == playIdx), oppo.Any(h => h.Item1 == playIdx));
}
}
// --- Phase 1: setup frames (CONSTANT: Deal once + each seat's Swap + Ready) -------------------
var deal = Receive(cl1, nameof(NetworkBattleUri.Deal));
Ingest(deal.Env, seat: true, nameof(NetworkBattleUri.Deal), deal.RawBody);
foreach (var (frames, seat) in new[] { (cl1, true), (cl2, false) })
{
var swap = Receive(frames, nameof(NetworkBattleUri.Swap));
Ingest(swap.Env, seat, nameof(NetworkBattleUri.Swap), swap.RawBody);
var ready = Receive(frames, nameof(NetworkBattleUri.Ready));
Ingest(ready.Env, seat, nameof(NetworkBattleUri.Ready), ready.RawBody);
}
// NEW VARIABLE 2: the spin pre-roll, applied at mulligan-end (after Ready, before the first
// turn-start draw) — where OperateReceive.StartOperate applies the Ready's spin in production.
// ONE application of 243 (shared stream, one client's worth of advance).
if (spin)
engine.DebugSpinPreroll(WireSpin);
// --- Phase 2: replay both clients' interleaved SENDS (the plays) ------------------------------
var sends = SendsWithRawBody(cl1, cl2)
.Where(x => !SkipUris.Contains(x.Frame.Uri))
.ToList();
foreach (var x in sends)
Ingest(x.Frame.Env, x.Seat, x.Frame.Uri, x.Frame.RawBody);
return new Cell(randomDraw, spin, divergences, firstNotFound);
}
private static IEnumerable<(CapturedFrame Frame, bool Seat)> SendsWithRawBody(
IReadOnlyList<CapturedFrame> cl1, IReadOnlyList<CapturedFrame> cl2)
{
return cl1.Where(f => f.Direction == "send").Select(f => (f, Seat: true))
.Concat(cl2.Where(f => f.Direction == "send").Select(f => (f, Seat: false)))
.OrderBy(x => x.f.Ts)
.Select(x => (x.f, x.Seat));
}
private static string Trim(string? s) => (s ?? "").Split(" @ ")[0];
[Test]
public void IsRandomDraw_plus_spin_preroll_isolation_matrix()
{
try
{
ConfirmSpin();
var baseline = Run(randomDraw: false, spin: false);
var rdOnly = Run(randomDraw: true, spin: false);
var rdSpin = Run(randomDraw: true, spin: true);
TestContext.WriteLine("=== ISOLATION MATRIX (setup-frames + real-seed held CONSTANT) ===");
TestContext.WriteLine("IsRandomDraw | spin | divergences");
TestContext.WriteLine($" false | no | {baseline.Divergences}");
TestContext.WriteLine($" true | no | {rdOnly.Divergences}");
TestContext.WriteLine($" true | +243 | {rdSpin.Divergences}");
DumpFirst("baseline {false,no}", baseline);
DumpFirst("rd-only {true,no}", rdOnly);
DumpFirst("rd+spin {true,+243}", rdSpin);
Assert.Pass(
$"MATRIX baseline={baseline.Divergences} rdOnly={rdOnly.Divergences} rdSpin={rdSpin.Divergences}");
}
catch (SuccessException) { throw; }
catch (Exception ex)
{
TestContext.WriteLine("EXPERIMENT THREW: " + ex);
throw;
}
}
private static void DumpFirst(string label, Cell c)
{
if (c.FirstNotFound is not { } d)
{
TestContext.WriteLine($"[{label}] no 'not found in hand' divergence.");
return;
}
TestContext.WriteLine($"[{label}] FIRST 'not found in hand': seat={d.Seat} uri={d.Uri} " +
$"wire playIdx={d.PlayIdx} stableRandomCount={d.StableRandomCount} reason={d.Reason}");
TestContext.WriteLine($" playIdx in self hand? {d.PlayIdxInSelfHand} in oppo hand? {d.PlayIdxInOppoHand}");
TestContext.WriteLine($" SELF (seat {d.Seat}) hand [{d.SelfHand.Count}]: " +
string.Join(" ", d.SelfHand.Select(h => $"(idx={h.Index},cid={h.CardId})")));
TestContext.WriteLine($" OPPO hand [{d.OppoHand.Count}]: " +
string.Join(" ", d.OppoHand.Select(h => $"(idx={h.Index},cid={h.CardId})")));
}
/// <summary>STEP 4 (payoff check): with the hypothesised fix applied {IsRandomDraw=true, +spin},
/// does the engine reach and RESOLVE cl1's spellboost play so PlayedCardSpellboost/PlayedCardCost
/// return real (non-zero) values? cl1's deck carries the spellboost-scaling follower 101314020 at
/// deck idx 10/21/25. We replay the {true,+243} cell and, after each accepted seat-A PlayActions,
/// probe whether any in-play/cemetery card has that id with a resolved cost/spellboost. We report
/// whether the spellboost play was ever reached at all.</summary>
[Test]
public void Spellboost_play_resolution_under_random_draw_plus_spin()
{
const int SpellboostCardId = 101314020;
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
var deckA = CaptureReplay.SelfDeckFrom(cl1);
var deckB = CaptureReplay.SelfDeckFrom(cl2);
var engine = new SessionBattleEngine();
engine.Setup(masterSeed: CaptureReplay.SeedFrom(cl1), seatADeck: deckA, seatBDeck: deckB);
engine.DebugSeedIdxChange(SeatASeed, SeatBSeed);
engine.DebugSetRandomDraw(true);
// setup frames
engine.Receive(Receive(cl1, nameof(NetworkBattleUri.Deal)).Env, isPlayerSeat: true);
foreach (var (frames, seat) in new[] { (cl1, true), (cl2, false) })
{
engine.Receive(Receive(frames, nameof(NetworkBattleUri.Swap)).Env, isPlayerSeat: seat);
engine.Receive(Receive(frames, nameof(NetworkBattleUri.Ready)).Env, isPlayerSeat: seat);
}
engine.DebugSpinPreroll(WireSpin);
int acceptedSeatAPlays = 0, divergedBeforeFirstPlay = 0;
bool spellboostResolved = false;
int sbCost = -999, sbCharge = -999;
var sends = SendsWithRawBody(cl1, cl2).Where(x => !SkipUris.Contains(x.Frame.Uri)).ToList();
bool sawFirstPlay = false;
foreach (var x in sends)
{
bool isPlay = x.Frame.Uri == nameof(NetworkBattleUri.PlayActions);
var r = engine.Receive(x.Frame.Env, isPlayerSeat: x.Seat);
if (isPlay && !sawFirstPlay) { sawFirstPlay = true; if (r.Diverged) divergedBeforeFirstPlay++; }
if (isPlay && x.Seat && !r.Diverged)
{
acceptedSeatAPlays++;
int playIdx = ReadPlayIdx(x.Frame.RawBody);
long id = engine.PlayedCardId(true, playIdx, 0);
if (id == SpellboostCardId)
{
spellboostResolved = true;
sbCost = engine.PlayedCardCost(true, playIdx, -1);
sbCharge = engine.PlayedCardSpellboost(true, playIdx, -1);
break;
}
}
}
TestContext.WriteLine($"[spellboost payoff] acceptedSeatAPlays={acceptedSeatAPlays} " +
$"divergedAtFirstPlay={divergedBeforeFirstPlay} spellboostResolved={spellboostResolved} " +
$"cost={sbCost} charge={sbCharge}");
// The replay diverges at the FIRST seat-A play (matrix shows playIdx=8 not in hand), so the
// engine never advances to the later spellboost play — the visible spellboost symptom is NOT
// fixed by {IsRandomDraw+spin} because the prerequisite (aligned draws) is not met.
Assert.That(divergedBeforeFirstPlay, Is.EqualTo(1),
"the FIRST seat-A play already diverges under {IsRandomDraw=true,+spin}");
Assert.That(spellboostResolved, Is.False,
"the spellboost play is never reached because the replay diverges at the first play");
}
private static void ConfirmSpin()
{
foreach (var fn in new[] { "battle_test_fresh_cl1.ndjson", "battle_test_fresh_cl2.ndjson" })
{
var frames = CaptureReplay.Load(fn);
var ready = Receive(frames, nameof(NetworkBattleUri.Ready));
var obj = JsonNode.Parse(ready.RawBody)!.AsObject();
int spin = obj.TryGetPropertyValue("spin", out var s) ? (int)s! : 0;
TestContext.WriteLine($"Confirmed {fn} Ready spin={spin}");
Assert.That(spin, Is.EqualTo(WireSpin), $"{fn} Ready spin must equal {WireSpin}");
}
}
}
}

View File

@@ -0,0 +1,182 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using NUnit.Framework;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions.Engine;
namespace SVSim.BattleEngine.Tests.SessionEngine
{
/// <summary>
/// PHASE 4 STEP 1 — Tier 2 capture-replay root-cause VERIFICATION (NOT a fix).
///
/// Replays the FRESH smoke captures (battle 907324319325) — battle_test_fresh_cl1/cl2.ndjson —
/// through a <see cref="SessionBattleEngine"/>, then measures whether the per-seat <c>idxChangeSeed</c>
/// the real Ready frame carries is what controls the "Target card was not found in hand cards"
/// divergence symptom.
///
/// FAITHFUL SETUP (the live ShadowIngest only feeds client SENDS, which contain NO Deal/Ready, so a
/// bare send-only replay can't even seat a hand — that conflates "missing Deal" with "missing
/// reshuffle"). To ISOLATE the reshuffle/seed effect we seat each seat's hand from its OWN client's
/// RECEIVE Deal + Swap + Ready (the frames that establish the hand and reach mulligan-end), then replay
/// both clients' interleaved SENDS (the plays). The Ready frame natively carries the real per-seat
/// idxChangeSeed (cl1=1430655717, cl2=661650374), and the engine's recovery receiver calls
/// <c>CreateXorShift</c> from it (NetworkBattleReceiver.cs:1125-1126). The A/B is then:
/// • WITH-SEED: ingest the Ready frame verbatim (idxChangeSeed present) -> XorShift active;
/// • SEED-STRIPPED: ingest the SAME Ready frame with idxChangeSeed forced to -1 -> XorShift inactive
/// (this is exactly the live shadow's effective state, since it never ingests the seed-bearing Ready).
/// The ONLY difference between the two runs is whether the seed reaches CreateXorShift.
///
/// DECK SETUP MECHANISM (feasibility crux, RESOLVED): each side's deck is reconstructed from the
/// capture's <c>Matched.selfDeck</c> (idx-&gt;cardId, the exact shuffled order the node also handed the
/// client) via <see cref="CaptureReplay.SelfDeckFrom"/>; the master seed from <c>Matched.selfInfo.seed</c>.
/// The deck IS in the socket capture — no external fixture needed.
/// </summary>
[TestFixture]
[NonParallelizable]
public class CaptureReplayReshuffleRootCauseTests
{
private static readonly HashSet<string> SkipUris = new()
{
nameof(NetworkBattleUri.Echo),
nameof(NetworkBattleUri.ChatStamp),
nameof(NetworkBattleUri.Gungnir),
};
private static readonly HashSet<string> MulliganUris = new()
{
nameof(NetworkBattleUri.Deal),
nameof(NetworkBattleUri.Swap),
nameof(NetworkBattleUri.Ready),
};
private sealed record ReplayOutcome(
int FrameCount, List<string> Divergences, bool AllDivergencesPostMulligan, bool SelfXorShiftActive);
// Re-parse a captured frame, overriding the Ready body's idxChangeSeed (and oppoIdxChangeSeed if
// present). Used to STRIP the seed (-1) to model the live shadow's seed-less state.
private static MsgEnvelope OverrideReadySeed(CapturedFrame f, int newSeed)
{
var obj = JsonNode.Parse(f.RawBody)!.AsObject();
obj["idxChangeSeed"] = newSeed;
if (obj.ContainsKey("oppoIdxChangeSeed")) obj["oppoIdxChangeSeed"] = newSeed;
return MsgEnvelope.FromJson(obj.ToJsonString());
}
/// <summary>Seat both hands from each client's receive Deal+Swap+Ready, then replay both clients'
/// interleaved SENDS. <paramref name="stripSeed"/> forces the Ready idxChangeSeed to -1 (the live
/// shadow's effective state). Returns divergences + the post-setup self XorShift state.</summary>
private static ReplayOutcome Replay(bool stripSeed)
{
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
var deckA = CaptureReplay.SelfDeckFrom(cl1);
var deckB = CaptureReplay.SelfDeckFrom(cl2);
Assert.That(deckA, Is.Not.Empty, "cl1 Matched.selfDeck must reconstruct seat A's deck");
Assert.That(deckB, Is.Not.Empty, "cl2 Matched.selfDeck must reconstruct seat B's deck");
var engine = new SessionBattleEngine();
engine.Setup(masterSeed: CaptureReplay.SeedFrom(cl1), seatADeck: deckA, seatBDeck: deckB);
Assert.That(engine.IsReady, Is.True, "engine must seat from the captured decks + seed");
var divergences = new List<string>();
bool sawMulliganEnd = false;
bool anyDivergencePreMulligan = false;
void Ingest(MsgEnvelope env, bool seat, string uri)
{
if (MulliganUris.Contains(uri)) sawMulliganEnd = true;
var r = engine.Receive(env, isPlayerSeat: seat);
if (r.Diverged)
{
divergences.Add($"seat={(seat ? "A" : "B")} {uri}: {Trim(r.RejectReason)}");
if (!sawMulliganEnd) anyDivergencePreMulligan = true;
}
}
// --- Phase 1: seat both hands from the receive setup frames ----------------------------------
// A single Deal seats BOTH opening hands (cl1's receive Deal carries self=A + oppo=B), so we
// ingest Deal ONCE (as seat A) — ingesting both clients' Deals would double-deal (NRE / "Sequence
// contains more than one"). Each seat's Swap then applies that seat's mulligan, and each seat's
// Ready carries THAT seat's idxChangeSeed (cl1's for A, cl2's for B; the recovery receiver consumes
// only the SELF seed per ingest, NetworkBattleReceiver.cs:1126), reaching mulligan-end per seat.
CapturedFrame Receive(IReadOnlyList<CapturedFrame> frames, string uri) =>
frames.First(f => f.Direction == "receive" && f.Uri == uri);
// Deal once (seat A's receive Deal seats both hands).
Ingest(Receive(cl1, nameof(NetworkBattleUri.Deal)).Env, seat: true, nameof(NetworkBattleUri.Deal));
// Each seat's mulligan swap, then each seat's Ready (its own seed).
foreach (var (frames, seat) in new[] { (cl1, true), (cl2, false) })
{
Ingest(Receive(frames, nameof(NetworkBattleUri.Swap)).Env, seat, nameof(NetworkBattleUri.Swap));
var ready = Receive(frames, nameof(NetworkBattleUri.Ready));
var readyEnv = stripSeed ? OverrideReadySeed(ready, -1) : ready.Env;
Ingest(readyEnv, seat, nameof(NetworkBattleUri.Ready));
}
bool selfActive = engine.SelfXorShiftActive;
// --- Phase 2: replay both clients' interleaved SENDS (the plays / turn ops) -------------------
var sends = CaptureReplay.InterleavedSends(cl1, cl2)
.Where(x => !SkipUris.Contains(x.Env.Uri.ToString()))
.ToList();
foreach (var (env, seat) in sends)
Ingest(env, seat, env.Uri.ToString());
return new ReplayOutcome(
FrameCount: sends.Count, divergences, !anyDivergencePreMulligan, selfActive);
}
private static string Trim(string? s) =>
(s ?? "").Split(" @ ")[0];
[Test]
public void Capture_replay_reproduces_post_mulligan_divergence_and_pins_what_the_seed_does_not_fix()
{
var withSeed = Replay(stripSeed: false);
var stripped = Replay(stripSeed: true);
TestContext.WriteLine($"WITH-SEED (Ready idxChangeSeed present): selfXorShiftActive={withSeed.SelfXorShiftActive} " +
$"playFrames={withSeed.FrameCount} divergences={withSeed.Divergences.Count}");
foreach (var d in withSeed.Divergences) TestContext.WriteLine(" DIVERGE " + d);
TestContext.WriteLine($"SEED-STRIPPED (idxChangeSeed=-1, the live shadow state): selfXorShiftActive={stripped.SelfXorShiftActive} " +
$"playFrames={stripped.FrameCount} divergences={stripped.Divergences.Count}");
foreach (var d in stripped.Divergences) TestContext.WriteLine(" DIVERGE " + d);
Assert.Multiple(() =>
{
// (1) The reported symptom reproduces DETERMINISTICALLY from the captures: the replay diverges,
// including the verbatim "Target card was not found in hand cards" exception.
Assert.That(withSeed.Divergences, Is.Not.Empty,
"the capture replay must reproduce the divergence symptom");
Assert.That(withSeed.Divergences.Any(d => d.Contains("not found in hand")), Is.True,
"the reported 'Target card was not found in hand cards' symptom must reproduce");
// (2) All divergences occur AFTER the mulligan barrier — consistent with a post-mulligan cause.
Assert.That(withSeed.AllDivergencesPostMulligan, Is.True, "with-seed divergences are post-mulligan");
Assert.That(stripped.AllDivergencesPostMulligan, Is.True, "stripped divergences are post-mulligan");
// (3) The wire seed DOES drive the engine's XorShift gate (NetworkBattleReceiver.cs:1126):
// present -> active, stripped (the live shadow's state) -> inactive.
Assert.That(withSeed.SelfXorShiftActive, Is.True,
"ingesting the real Ready (idxChangeSeed present) activates the engine's XorShift");
Assert.That(stripped.SelfXorShiftActive, Is.False,
"stripping idxChangeSeed (the live shadow's state) leaves the XorShift inactive");
// (4) THE KEY VERIFICATION FINDING — activating the XorShift via the wire seed does NOT, on its
// own, change the divergence count. The engine's recovery/watch RECEIVE path never performs
// the post-mulligan full-deck reshuffle the live client does: the XorShift's GetChangeInt is
// consumed ONLY by AddToDeckCardIndexChange (BattlePlayerBase.cs:3079) for cards added to the
// deck AFTER mulligan-end, and the per-turn draw is engine-computed off the (un-reshuffled)
// deck order, not driven by the wire's `move idx`. So "feed the seed" alone does NOT fix the
// desync headless — the eventual fix must also make the engine reshuffle the deck post-
// mulligan to match the client (or drive the draw from the wire idx). We PIN this here.
Assert.That(stripped.Divergences.Count, Is.EqualTo(withSeed.Divergences.Count),
"VERIFIED: activating the XorShift via the wire seed alone does NOT change the divergence " +
"count — the engine's receive path does not reshuffle the deck, so the seed is necessary " +
"but NOT sufficient (the fix needs the reshuffle too, not just the seed)");
});
}
}
}

View File

@@ -0,0 +1,215 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions.Engine;
namespace SVSim.BattleEngine.Tests.SessionEngine
{
/// <summary>
/// PHASE 4 — OPTION-A VIABILITY PROBE (TEST-ONLY; no production fix; no Engine/*.cs edits).
///
/// QUESTION: can a per-seat RNG router in the headless engine reliably attribute each StableRandom roll
/// to the correct seat — so two seats can draw from two independent same-seeded sub-streams (mirroring
/// two real clients, each with its OWN _stableRandom)?
///
/// METHOD: replay battle_test_fresh_cl1/cl2 through a <see cref="SessionBattleEngine"/> whose mgr RNG is
/// a logging source. On EVERY roll it records (a) the seat signals the mgr can read from its own state
/// (GetBattlePlayer(true/false).IsSelfTurn — the richest seat signal a mgr-level StableRandom override
/// sees; there is NO "current operating seat" field on the mgr), and (b) the live call stack (where the
/// acting seat is actually visible: MulliganCtrl._battlePlayer / BattlePlayerBase.LotteryRandomDrawCard /
/// OperateReceive.StartOperate spin pre-roll). We dump the rolls for the mulligan lotteries, the first
/// turn draws, and the spin pre-roll, and classify each — reporting whether the seat is UNAMBIGUOUS from
/// mgr STATE vs only from the STACK.
/// </summary>
[TestFixture]
[NonParallelizable]
public class CaptureReplayRngSeatAttributionProbeTests
{
private const int SeatASeed = 1430655717; // cl1 Ready idxChangeSeed
private const int SeatBSeed = 661650374; // cl2 Ready idxChangeSeed
private const int WireSpin = 243;
private static readonly HashSet<string> SkipUris = new()
{
nameof(NetworkBattleUri.Echo),
nameof(NetworkBattleUri.ChatStamp),
nameof(NetworkBattleUri.Gungnir),
};
private static CapturedFrame Receive(IReadOnlyList<CapturedFrame> frames, string uri) =>
frames.First(f => f.Direction == "receive" && f.Uri == uri);
private static IEnumerable<(CapturedFrame Frame, bool Seat)> SendsInTsOrder(
IReadOnlyList<CapturedFrame> cl1, IReadOnlyList<CapturedFrame> cl2) =>
cl1.Where(f => f.Direction == "send").Select(f => (f, Seat: true))
.Concat(cl2.Where(f => f.Direction == "send").Select(f => (f, Seat: false)))
.OrderBy(x => x.f.Ts)
.Select(x => (x.f, x.Seat));
[Test]
public void Roll_log_reveals_whether_acting_seat_is_attributable_from_state_or_only_stack()
{
var cl1 = CaptureReplay.Load("battle_test_fresh_cl1.ndjson");
var cl2 = CaptureReplay.Load("battle_test_fresh_cl2.ndjson");
var deckA = CaptureReplay.SelfDeckFrom(cl1);
var deckB = CaptureReplay.SelfDeckFrom(cl2);
// (5) seeds
int seedA = CaptureReplay.SeedFrom(cl1);
int seedB = CaptureReplay.SeedFrom(cl2);
TestContext.WriteLine($"=== SEEDS (Matched.selfInfo.seed) ===");
TestContext.WriteLine($" cl1 seed = {seedA}");
TestContext.WriteLine($" cl2 seed = {seedB}");
TestContext.WriteLine($" SAME? {seedA == seedB} (Ready idxChangeSeed cl1={SeatASeed} cl2={SeatBSeed} — DIFFERENT)");
TestContext.WriteLine("");
var engine = new SessionBattleEngine();
var log = engine.DebugSetupWithRollLog(masterSeed: seedA, seatADeck: deckA, seatBDeck: deckB);
Assert.That(engine.IsReady, Is.True);
engine.DebugSeedIdxChange(SeatASeed, SeatBSeed);
engine.DebugSetRandomDraw(true); // the gate that makes draws actually ROLL
// mark roll-log boundaries so we can bucket the rolls by phase
int Mark() => log.Count;
int beforeDeal = Mark();
engine.Receive(Receive(cl1, nameof(NetworkBattleUri.Deal)).Env, isPlayerSeat: true);
int afterDeal = Mark();
// seat A mulligan (Swap+Ready) then seat B mulligan
engine.Receive(Receive(cl1, nameof(NetworkBattleUri.Swap)).Env, isPlayerSeat: true);
int afterSwapA = Mark();
engine.Receive(Receive(cl1, nameof(NetworkBattleUri.Ready)).Env, isPlayerSeat: true);
int afterReadyA = Mark();
engine.Receive(Receive(cl2, nameof(NetworkBattleUri.Swap)).Env, isPlayerSeat: false);
int afterSwapB = Mark();
engine.Receive(Receive(cl2, nameof(NetworkBattleUri.Ready)).Env, isPlayerSeat: false);
int afterReadyB = Mark();
// spin pre-roll (one client's 243 advance, applied once on the shared stream)
engine.DebugSpinPreroll(WireSpin);
int afterSpin = Mark();
// replay both clients' interleaved sends (the plays + turn ops -> turn-start draws fire here)
var sends = SendsInTsOrder(cl1, cl2).Where(x => !SkipUris.Contains(x.Frame.Uri)).ToList();
foreach (var x in sends)
engine.Receive(x.Frame.Env, isPlayerSeat: x.Seat);
int afterSends = Mark();
TestContext.WriteLine("=== ROLL-COUNT BY PHASE (IsRandomDraw=true) ===");
TestContext.WriteLine($" Deal : {afterDeal - beforeDeal}");
TestContext.WriteLine($" Swap A : {afterSwapA - afterDeal}");
TestContext.WriteLine($" Ready A (mulligan): {afterReadyA - afterSwapA}");
TestContext.WriteLine($" Swap B : {afterSwapB - afterReadyA}");
TestContext.WriteLine($" Ready B (mulligan): {afterReadyB - afterSwapB}");
TestContext.WriteLine($" spin pre-roll : {afterSpin - afterReadyB} (expected {WireSpin})");
TestContext.WriteLine($" all sends/plays : {afterSends - afterSpin}");
TestContext.WriteLine($" TOTAL : {log.Count}");
TestContext.WriteLine("");
DumpRange("DEAL", log, beforeDeal, afterDeal);
DumpRange("SWAP A (mulligan lottery, seat A)", log, afterDeal, afterSwapA);
DumpRange("READY A (mulligan, seat A)", log, afterSwapA, afterReadyA);
DumpRange("SWAP B (mulligan lottery, seat B)", log, afterReadyA, afterSwapB);
DumpRange("READY B (mulligan, seat B)", log, afterSwapB, afterReadyB);
DumpSpinSummary("SPIN PRE-ROLL", log, afterReadyB, afterSpin);
// first ~12 of the play phase covers the early turn-start draws for both seats
DumpRange("FIRST PLAY-PHASE ROLLS (turn draws + effects)", log, afterSpin,
System.Math.Min(afterSpin + 12, afterSends));
// === STATE-vs-STACK attribution analysis ===
AnalyzeAttribution(log, afterSpin);
Assert.Pass($"probe complete: {log.Count} rolls logged; see TestContext output for attribution analysis");
}
private static void DumpRange(string label, IReadOnlyList<SessionBattleEngine.RollEntry> log, int from, int to)
{
TestContext.WriteLine($"--- {label} [rolls {from}..{to - 1}] ({to - from} rolls) ---");
for (int i = from; i < to; i++)
{
var e = log[i];
TestContext.WriteLine($" #{e.Index} {e.Api}(arg={e.Arg}) | mgrState: self.IsSelfTurn={e.SelfIsSelfTurn} oppo.IsSelfTurn={e.OppoIsSelfTurn} | classify={Classify(e)}");
TestContext.WriteLine($" stack: {e.Stack}");
}
if (to - from == 0) TestContext.WriteLine(" (none)");
TestContext.WriteLine("");
}
private static void DumpSpinSummary(string label, IReadOnlyList<SessionBattleEngine.RollEntry> log, int from, int to)
{
TestContext.WriteLine($"--- {label} [rolls {from}..{to - 1}] ({to - from} rolls) ---");
if (to - from > 0)
{
var first = log[from];
TestContext.WriteLine($" first spin roll #{first.Index}: self.IsSelfTurn={first.SelfIsSelfTurn} oppo.IsSelfTurn={first.OppoIsSelfTurn}");
TestContext.WriteLine($" stack: {first.Stack}");
bool allStateIdentical = log.Skip(from).Take(to - from)
.All(e => e.SelfIsSelfTurn == first.SelfIsSelfTurn && e.OppoIsSelfTurn == first.OppoIsSelfTurn);
bool allViaStartOperate = log.Skip(from).Take(to - from).All(e => e.Stack.Contains("StartOperate"));
TestContext.WriteLine($" all {to - from} spin rolls have identical mgr seat-state? {allStateIdentical}");
TestContext.WriteLine($" all {to - from} spin rolls routed via OperateReceive.StartOperate? {allViaStartOperate}");
}
TestContext.WriteLine("");
}
// Best-effort classification from the STACK (the ground truth of who is rolling).
private static string Classify(SessionBattleEngine.RollEntry e)
{
string s = e.Stack;
if (s.Contains("StartOperate")) return "SPIN-PREROLL";
if (s.Contains("_LotMulliganCardIndex") || s.Contains("MulliganCtrl")) return "MULLIGAN-LOTTERY";
if (s.Contains("LotteryRandomDrawCard") || s.Contains("RandomCardDraw")) return "TURN/EFFECT-DRAW";
if (s.Contains("SkillRandomSelectFilter")) return "SKILL-FILTER-DRAW";
return "OTHER-EFFECT";
}
private static void AnalyzeAttribution(IReadOnlyList<SessionBattleEngine.RollEntry> log, int playPhaseStart)
{
TestContext.WriteLine("=== STATE-vs-STACK ATTRIBUTION ANALYSIS ===");
// 1) Does mgr-state (IsSelfTurn flags) ever change across the whole replay? If both flags are
// pinned at setup values (self=true/oppo=false) the entire time, mgr-state CANNOT distinguish
// seats — every roll looks identical from mgr state.
var distinctStates = log
.Select(e => (e.SelfIsSelfTurn, e.OppoIsSelfTurn))
.Distinct()
.ToList();
TestContext.WriteLine($" distinct mgr seat-states observed across ALL {log.Count} rolls: {distinctStates.Count}");
foreach (var st in distinctStates)
TestContext.WriteLine($" (self.IsSelfTurn={st.Item1}, oppo.IsSelfTurn={st.Item2})");
// 2) For the mulligan lotteries: seat A's 6 rolls then seat B's 6 rolls happen back-to-back. Are
// their mgr-states distinguishable? (They should NOT be — IsSelfTurn isn't toggled during
// mulligan; both lotteries run with the same setup-time flags.)
var mulliganRolls = log.Where(e => Classify(e) == "MULLIGAN-LOTTERY").ToList();
var mulliganStates = mulliganRolls.Select(e => (e.SelfIsSelfTurn, e.OppoIsSelfTurn)).Distinct().Count();
TestContext.WriteLine($" mulligan-lottery rolls: {mulliganRolls.Count}; distinct mgr seat-states among them: {mulliganStates}");
TestContext.WriteLine($" -> seat attributable from mgr STATE alone? {(mulliganStates >= 2 ? "MAYBE" : "NO (state identical for both seats' lotteries)")}");
bool mulliganSeatInStack = mulliganRolls.All(e => e.Stack.Contains("Mulligan") || e.Stack.Contains("_LotMulligan"));
TestContext.WriteLine($" -> mulligan rolls carry a MulliganCtrl frame on the stack? {mulliganSeatInStack}");
// 3) For the play-phase draws: are turn-start draws present at all, and do their mgr-states track
// the acting seat (i.e. does IsSelfTurn flip to identify whose turn/draw it is)?
var drawRolls = log.Skip(playPhaseStart)
.Where(e => Classify(e) is "TURN/EFFECT-DRAW" or "SKILL-FILTER-DRAW")
.ToList();
TestContext.WriteLine($" play-phase draw/filter rolls: {drawRolls.Count}");
if (drawRolls.Count > 0)
{
var drawStates = drawRolls.Select(e => (e.SelfIsSelfTurn, e.OppoIsSelfTurn)).Distinct().Count();
TestContext.WriteLine($" distinct mgr seat-states among draw rolls: {drawStates}");
}
TestContext.WriteLine("");
TestContext.WriteLine(" INTERPRETATION:");
TestContext.WriteLine(" * If distinct mgr seat-states == 1 for a phase, the StableRandom override CANNOT");
TestContext.WriteLine(" attribute that phase's rolls to a seat from mgr state — only the call STACK");
TestContext.WriteLine(" (MulliganCtrl._battlePlayer / BattlePlayerBase 'this' / OperateReceive._isPlayer)");
TestContext.WriteLine(" names the acting seat.");
}
}
}

View File

@@ -0,0 +1,27 @@
using System.Linq;
using NUnit.Framework;
namespace SVSim.BattleEngine.Tests.SessionEngine
{
[TestFixture]
public class CaptureReplayTests
{
[Test]
public void Load_parses_frames_and_extracts_self_deck()
{
var frames = CaptureReplay.Load("battle_test_cl1.ndjson");
Assert.That(frames, Is.Not.Empty);
var deck = CaptureReplay.SelfDeckFrom(frames);
Assert.That(deck, Is.Not.Empty, "Matched.selfDeck should parse");
Assert.That(deck.Count, Is.EqualTo(40), "a standard deck is 40 cards");
// Send PlayActions carry their URI at the top level (body.uri == None); the helper must
// resolve it correctly, not drop it to None.
Assert.That(frames.Any(f => f.Direction == "send" && f.Uri == "PlayActions"),
Is.True, "send PlayActions URI resolved from top level");
Assert.That(CaptureReplay.SeedFrom(frames), Is.GreaterThan(0), "Matched.selfInfo.seed parsed");
}
}
}

View File

@@ -0,0 +1,71 @@
using System.Linq;
using NUnit.Framework;
using SVSim.BattleNode.Sessions.Engine;
namespace SVSim.BattleEngine.Tests.SessionEngine
{
[TestFixture]
public class SessionEngineConstructionTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[Test]
public void SessionBattleEngine_instantiates_and_is_not_ready_before_setup()
{
var engine = new SessionBattleEngine();
Assert.That(engine.IsReady, Is.False);
}
[Test]
public void Setup_builds_two_seat_network_battle_headless()
{
// Load every card id the two test decks reference so CardMaster can resolve them.
var deckA = Enumerable.Repeat(100011010L, 40).ToList(); // vanilla 1/2 follower x40
var deckB = Enumerable.Repeat(100011010L, 40).ToList();
HeadlessCardMaster.Load(100011010);
var engine = new SessionBattleEngine();
Assert.DoesNotThrow(() => engine.Setup(masterSeed: 12345, seatADeck: deckA, seatBDeck: deckB));
Assert.That(engine.IsReady, Is.True);
}
[Test]
public void Receive_one_playactions_resolves_headless()
{
// SUPERSEDED by the node-native oracle (SVSim.UnitTests HeadlessConductorTests). This test
// predates the M-HC-0b view-untangle: before it, the receive conductor resolved NOTHING
// headless (every InstantVfx the conductor fused the mutation into was no-op'd by the shared
// VfxMgr, and OperateReceive.OnReceiveDeal was never wired), so a play "ingested" without
// touching state and trivially did not reject. Now the conductor RESOLVES (HeadlessConductor
// VfxMgr runs the InstantVfx; the deal seats the hand). This test feeds the first captured
// `send PlayActions` WITHOUT first replaying the capture's Deal/mulligan, so the played card
// is not in the seated hand and the now-live resolution correctly rejects
// (RemoveSpellCardFromHand: not found). Replaying the capture's Deal first does NOT fix it:
// the seated deck order can't reproduce the capture's post-mulligan idx references (the
// documented capture-replay draw-misalignment artifact — see memory
// project_battle_headless_conductor: "validate via node-native battles"). The valid headless
// play oracle is now HeadlessConductorTests.Vanilla_play_resolves_on_engine_state_headless.
Assert.Ignore("Superseded by node-native HeadlessConductorTests (M-HC-0b). Capture-replay " +
"draw-misalignment makes a captured play unresolvable against a node-seated deck; the " +
"node-native harness is the post-M-HC-0b oracle. Revive if capture-replay alignment lands.");
var cl1 = CaptureReplay.Load("battle_test_cl1.ndjson");
var deck = CaptureReplay.SelfDeckFrom(cl1);
// Load ALL deck ids in ONE call: HeadlessCardMaster.Load replaces the static CardMaster each
// call, so a per-id loop would leave only the last card resolvable.
HeadlessCardMaster.Load(deck.Select(x => (int)x).Distinct().ToArray());
var engine = new SessionBattleEngine();
engine.Setup(CaptureReplay.SeedFrom(cl1), seatADeck: deck, seatBDeck: deck);
var firstPlay = cl1.First(f => f.Direction == "send" && f.Uri == "PlayActions");
var result = engine.Receive(firstPlay.Env, isPlayerSeat: true);
Assert.That(result.RejectReason, Is.Null, $"ingest threw/rejected: {result.RejectReason}");
Assert.That(result.Accepted, Is.True);
}
}
}

View File

@@ -0,0 +1,100 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions.Engine;
namespace SVSim.BattleEngine.Tests.SessionEngine
{
[TestFixture]
public class SessionEngineShadowReplayTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
// Frames that are transport/keepalive, not game actions — not ingested.
private static readonly HashSet<string> SkipUris = new()
{
nameof(NetworkBattleUri.Echo),
nameof(NetworkBattleUri.ChatStamp),
nameof(NetworkBattleUri.Gungnir),
};
[Test]
public void Shadow_replay_of_captured_battle_tracks_state_without_desync()
{
// SUPERSEDED by the node-native oracle (SVSim.UnitTests HeadlessConductorTests). This test's
// "0 rejects" used to pass VACUOUSLY: before the M-HC-0b view-untangle the receive conductor
// resolved NOTHING headless (InstantVfx mutations no-op'd; OnReceiveDeal unwired), so no
// captured frame could diverge because none was applied. The retracted "shadow tracks the
// capture" claim is documented in memory project_battle_node_engine_shadow / _headless_conductor.
// Now that the conductor RESOLVES, replaying a captured stream against a node-seated deck hits
// the documented capture-replay draw-misalignment: the seated deck order can't reproduce the
// capture's post-mulligan idx references, so played cards aren't in the seated hand
// (HandCardToField/RemoveSpellCardFromHand: not found). The decision (memory
// project_battle_headless_conductor) is to validate headless resolution via NODE-NATIVE
// battles, not capture replay. The node-native oracle now covers Deal+Play.
Assert.Ignore("Superseded by node-native HeadlessConductorTests (M-HC-0b). Capture-replay " +
"against a node-seated deck hits the documented draw-misalignment artifact once the " +
"receive path actually resolves. Revive if a capture-replay alignment path lands.");
var cl1 = CaptureReplay.Load("battle_test_cl1.ndjson");
var cl2 = CaptureReplay.Load("battle_test_cl2.ndjson");
var deckA = CaptureReplay.SelfDeckFrom(cl1);
var deckB = CaptureReplay.SelfDeckFrom(cl2);
// One Load call with every id — Load replaces the static master each call.
HeadlessCardMaster.Load(deckA.Concat(deckB).Select(x => (int)x).Distinct().ToArray());
var engine = new SessionBattleEngine();
engine.Setup(masterSeed: CaptureReplay.SeedFrom(cl1), seatADeck: deckA, seatBDeck: deckB);
// Single-client full-stream replay (cl1 as the player seat): cl1's SENT frames are its own
// actions (seat=true); its RECEIVED frames are the opponent/server actions (seat=false),
// incl. the Deal that establishes both hands. This is exactly the stream cl1's receiver
// processed, in capture (ts) order. (The node-side both-clients-sends model is exercised
// live in Task 7; here we validate engine tracking against ground truth.)
var stream = cl1.Where(f => !SkipUris.Contains(f.Uri))
.OrderBy(f => f.Ts)
.ToList();
var rejects = new List<string>();
var violations = new List<string>();
foreach (var f in stream)
{
bool seat = f.Direction == "send";
var r = engine.Receive(f.Env, isPlayerSeat: seat);
if (r.RejectReason is not null)
rejects.Add($"{f.Direction} {f.Uri}: {r.RejectReason}");
if (f.Uri == nameof(NetworkBattleUri.TurnEnd))
CheckInvariants(engine, violations, atUri: f.Uri);
}
foreach (var line in rejects) TestContext.WriteLine("REJECT " + line);
foreach (var line in violations) TestContext.WriteLine("VIOLATION " + line);
TestContext.WriteLine($"frames={stream.Count} rejects={rejects.Count} violations={violations.Count}");
Assert.Multiple(() =>
{
Assert.That(rejects, Is.Empty, "engine diverged / rejected a captured frame");
Assert.That(violations, Is.Empty, "engine state left a structural invariant");
});
}
private static void CheckInvariants(SessionBattleEngine engine, List<string> violations, string atUri)
{
foreach (var seat in new[] { true, false })
{
int life = engine.LeaderLife(seat), pp = engine.Pp(seat);
int board = engine.BoardCount(seat), hand = engine.HandCount(seat);
if (life is < 0 or > 20) violations.Add($"{atUri} seat={seat} life={life}");
if (pp is < 0 or > 10) violations.Add($"{atUri} seat={seat} pp={pp}");
if (board is < 0 or > 7) violations.Add($"{atUri} seat={seat} board={board}");
if (hand is < 0 or > 9) violations.Add($"{atUri} seat={seat} hand={hand}");
}
}
}
}

View File

@@ -0,0 +1,33 @@
using NUnit.Framework;
using SVSim.BattleNode.Sessions.Engine;
using System.Linq;
using SVSim.BattleEngine.Tests;
namespace SVSim.BattleEngine.Tests.SessionEngine;
[TestFixture]
public class SessionEngineSpellboostTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[Test]
public void EngineGlobalInit_makes_a_fresh_engine_ready()
{
EngineGlobalInit.EnsureInitialized();
var cl1 = CaptureReplay.Load("battle_test_cl1.ndjson");
var cl2 = CaptureReplay.Load("battle_test_cl2.ndjson");
var deckA = CaptureReplay.SelfDeckFrom(cl1);
var deckB = CaptureReplay.SelfDeckFrom(cl2);
// Belt-and-suspenders (matches the sibling tests): load the decks into the harness master so
// this test never depends on global card-master contents. EnsureInitialized() above still
// proves EngineGlobalInit's own path works.
foreach (var id in deckA.Concat(deckB).Distinct()) HeadlessCardMaster.Load((int)id);
var engine = new SessionBattleEngine();
Assert.DoesNotThrow(() => engine.Setup(masterSeed: 12345, seatADeck: deckA, seatBDeck: deckB));
Assert.That(engine.IsReady, Is.True, "engine must be ready after EngineGlobalInit (carried-risk fix)");
}
}

View File

@@ -0,0 +1,111 @@
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M5 (next-hardest deterministic card): a when_play SUMMON_TOKEN spell resolves to correct
// authoritative state HEADLESS via the same IsForecast/IsRecovery + ActionProcessor path the
// M2 vanilla follower / M3 fixed-damage spell / M4 self-buff follower proved (design §5 / DP4 +
// M3+ resume recipe). The new oracle dimension over M2-M4 is a BOARD-COUNT DELTA from a
// SKILL-CREATED card: the spell's `summon_token=100011020` must place exactly one NEW follower
// token (id 100011020, a neutral 2/2) onto the caster's board — a card that was never in the
// hand or deck. This is the first headless run of the PUBLIC prefab card-creation path
// (CardCreatorBase.CreateCard, createNullView:false), so it stresses the view shim in a way the
// earlier null-view-seam milestones did not.
[TestFixture]
public class SummonTokenOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
private static void SetPrivateField(object obj, string name, object value)
{
var t = obj.GetType();
var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); }
Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}");
f.SetValue(obj, value);
}
[Test]
public void Summon_token_spell_places_a_new_token_on_the_board()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
// Minimal opponent/turn wiring (see M2-M4 oracles): opponent refs + active turn flag.
// The summon resolves onto the active player's own board (`summon_side` defaults to self).
SetPrivateField(player, "_opponentBattlePlayer", enemy);
SetPrivateField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
// Seed leader life: this spell deals no damage, but the play-legality gate still rejects a
// play when a leader reads as a 0-life game-over state (M3 learning).
HeadlessEngineEnv.InitLeaderLife(mgr);
// Seed the card-template prefabs the engine's internal (createNullView:false) summon
// creation path clones — the bare construction path leaves SBattleLoad null.
HeadlessEngineEnv.InitCardTemplates(mgr);
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.TokenSpellId);
// Place the summon-token spell in the active player's hand with PP to spare; empty board.
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.TokenSpellId, 1, isPlayer: true, mgr);
player.HandCardList.Add(card);
player.Pp = 10;
// Pre-state snapshot. ClassAndInPlayCardList holds the leader (index 0) on an empty board.
int ppBefore = player.Pp;
int handBefore = player.HandCardList.Count;
int playerInplayBefore = player.ClassAndInPlayCardList.Count;
int enemyHandBefore = enemy.HandCardList.Count;
int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count;
int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life;
// Resolve the play through the real engine.
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
var ap = new ActionProcessor(pair);
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null),
"ActionProcessor.PlayCard threw on a summon-token spell");
// Oracle: the board-count delta + summoned token identity is the new M5 dimension; the rest
// are the §5 spell-shaped invariants proven by M3.
Assert.Multiple(() =>
{
// Primary M5 assertion: exactly one NEW card is on the player's board (the summoned
// token), and it is the token id with its CardCSVData base stats — proving the skill
// CREATED a card, not just moved the played one.
Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(playerInplayBefore + 1),
"player board count not +1 (the summoned token did not land)");
var token = player.ClassAndInPlayCardList
.SingleOrDefault(c => c.CardId == HeadlessEngineEnv.SummonedTokenId);
Assert.That(token, Is.Not.Null, "summoned token (id 100011020) not found on the board");
Assert.That(token.Atk, Is.EqualTo(HeadlessEngineEnv.SummonedTokenAtk), "token atk != base");
Assert.That(token.Life, Is.EqualTo(HeadlessEngineEnv.SummonedTokenLife), "token life != base");
// The summoned token is NOT the played card.
Assert.That(token, Is.Not.SameAs(card), "summoned token is the played spell itself");
// Cost paid.
Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost");
// The spell leaves hand and (being a spell) does NOT itself occupy the board.
Assert.That(player.HandCardList, Does.Not.Contain(card), "spell still in hand");
Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1");
Assert.That(player.ClassAndInPlayCardList, Does.Not.Contain(card), "spell wrongly placed on the board");
// Opponent unchanged (the summon targets the caster's own board).
Assert.That(enemy.HandCardList.Count, Is.EqualTo(enemyHandBefore), "opponent hand changed");
Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore), "opponent board changed");
Assert.That(enemy.ClassAndInPlayCardList[0].Life, Is.EqualTo(enemyLeaderLifeBefore), "opponent leader life changed");
});
}
}
}

View File

@@ -0,0 +1,109 @@
using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M6 (the first TARGET-SELECTION card): a when_play TARGETED-DAMAGE spell resolves to correct
// authoritative state HEADLESS via the same IsForecast/IsRecovery + ActionProcessor path the
// M2-M5 cards proved — but for the FIRST time exercising the `selectedCards` path of
// ActionProcessor.PlayCard (Engine/Wizard.Battle/ActionProcessor.cs:401, dormant until now;
// M2-M5 all passed selectedCards: null). The new oracle dimension is SELECTION ROUTING: with
// TWO followers on the enemy board and ONE passed as `selectedCards`, the spell's `damage=5`
// must hit the SELECTED follower and leave the un-selected one untouched. A plain "a follower
// took damage" assertion would false-pass; reading the differential (selected -5, un-selected 0)
// is what proves the selectedCards path routes the effect to the chosen target. Load-bearing is
// confirmed by swapping which follower is selected and watching the damage follow the selection.
[TestFixture]
public class TargetedDamageSpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
private static void SetPrivateField(object obj, string name, object value)
{
var t = obj.GetType();
var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); }
Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}");
f.SetValue(obj, value);
}
[Test]
public void Targeted_damage_spell_hits_only_the_selected_enemy_follower()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
// Minimal opponent/turn wiring (see M2-M5 oracles): opponent refs + active turn flag. The
// spell's target resolver walks player -> opponent -> opponent's in-play followers.
SetPrivateField(player, "_opponentBattlePlayer", enemy);
SetPrivateField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
// Seed leader life so neither leader reads as a 0-life game-over state (blocks plays, M3).
HeadlessEngineEnv.InitLeaderLife(mgr);
// Put TWO vanilla followers on the ENEMY board (the new M6 setup). Both survive the 5
// damage, so the oracle reads a differential life-delta rather than depending on death.
var selected = HeadlessEngineEnv.PutFollowerInPlay(mgr, HeadlessEngineEnv.SelectTargetFollowerId, 0, isPlayer: false);
var unselected = HeadlessEngineEnv.PutFollowerInPlay(mgr, HeadlessEngineEnv.UnselectTargetFollowerId, 1, isPlayer: false);
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.TargetSpellId);
// Place the targeted-damage spell in the active player's hand with PP to spare.
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.TargetSpellId, 1, isPlayer: true, mgr);
player.HandCardList.Add(card);
player.Pp = 10;
// Pre-state snapshot.
int ppBefore = player.Pp;
int handBefore = player.HandCardList.Count;
int playerInplayBefore = player.ClassAndInPlayCardList.Count;
int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count;
int selectedLifeBefore = selected.Life;
int unselectedLifeBefore = unselected.Life;
int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life;
// Resolve the play through the real engine, passing the chosen target via selectedCards
// (the M6 first — every prior milestone passed null).
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
var ap = new ActionProcessor(pair);
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: new List<BattleCardBase> { selected }),
"ActionProcessor.PlayCard threw on a targeted-damage spell");
// Oracle: selection routing is the new M6 dimension; the rest are the §5 spell-shaped invariants.
Assert.Multiple(() =>
{
// PRIMARY M6 assertions: the SELECTED follower takes exactly the spell's damage...
Assert.That(selected.Life, Is.EqualTo(selectedLifeBefore - HeadlessEngineEnv.TargetSpellDamage),
"selected follower did not take the spell's damage");
// ...and the UN-SELECTED follower is untouched (proves routing, not a blanket hit).
Assert.That(unselected.Life, Is.EqualTo(unselectedLifeBefore),
"un-selected follower was damaged (effect not routed to the selection)");
// Both followers survive => still on the enemy board; leader unchanged.
Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore),
"enemy board count changed (a target unexpectedly left the board)");
Assert.That(enemy.ClassAndInPlayCardList[0].Life, Is.EqualTo(enemyLeaderLifeBefore),
"opponent leader life changed (damage hit the leader, not the selected follower)");
// Cost paid.
Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost");
// Spell leaves hand and (being a spell) does NOT occupy the board.
Assert.That(player.HandCardList, Does.Not.Contain(card), "spell still in hand");
Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1");
Assert.That(player.ClassAndInPlayCardList, Does.Not.Contain(card), "spell wrongly placed on the board");
Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(playerInplayBefore), "player board count changed");
});
}
}
}

View File

@@ -0,0 +1,115 @@
using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M7 (the first card to prove follower DEATH / board-removal): a when_play TARGETED-DESTROY spell
// resolves to correct authoritative state HEADLESS via the same IsForecast/IsRecovery +
// ActionProcessor + selectedCards path M6 proved — but for the FIRST time exercising a mechanic
// that REMOVES a card from the board. M2-M6 only ever ADDED to / mutated stats of cards already in
// play; none proved the engine commits board REMOVAL inside the authoritative part of PlayCard
// (rather than the cosmetic post-Process tail the prior docs flag). The new oracle dimension is
// BOARD REMOVAL: with TWO followers on the enemy board and ONE passed as `selectedCards`, the
// `destroy` must remove exactly the SELECTED follower (enemy board count -1, selected gone, landed
// in the enemy CemeteryList) while leaving the un-selected follower on the board. The un-selected-
// survives assertion is load-bearing the same way M4's delta-vs-base and M6's differential were:
// it distinguishes "the destroy was routed to the selection" from "a blanket board wipe" — and is
// confirmed by the routing already proven in M6 (the effect follows the selectedCards entry).
[TestFixture]
public class TargetedDestroySpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
private static void SetPrivateField(object obj, string name, object value)
{
var t = obj.GetType();
var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); }
Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}");
f.SetValue(obj, value);
}
[Test]
public void Targeted_destroy_spell_removes_only_the_selected_enemy_follower()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
// Minimal opponent/turn wiring (see M2-M6 oracles): opponent refs + active turn flag. The
// destroy's target resolver walks player -> opponent -> opponent's in-play followers.
SetPrivateField(player, "_opponentBattlePlayer", enemy);
SetPrivateField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
// Seed leader life so neither leader reads as a 0-life game-over state (blocks plays, M3).
HeadlessEngineEnv.InitLeaderLife(mgr);
// Put TWO vanilla followers on the ENEMY board (the M6 setup). destroy is unconditional, so
// their stats are irrelevant — distinct ids only so the selected vs un-selected can't be
// confused. The selected one is destroyed; the un-selected one must survive.
var selected = HeadlessEngineEnv.PutFollowerInPlay(mgr, HeadlessEngineEnv.DestroyTargetFollowerId, 0, isPlayer: false);
var unselected = HeadlessEngineEnv.PutFollowerInPlay(mgr, HeadlessEngineEnv.DestroyOtherFollowerId, 1, isPlayer: false);
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.DestroySpellId);
// Place the targeted-destroy spell in the active player's hand with PP to spare.
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.DestroySpellId, 1, isPlayer: true, mgr);
player.HandCardList.Add(card);
player.Pp = 10;
// Pre-state snapshot.
int ppBefore = player.Pp;
int handBefore = player.HandCardList.Count;
int playerInplayBefore = player.ClassAndInPlayCardList.Count;
int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count;
int enemyCemeteryBefore = enemy.CemeteryList.Count;
int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life;
// Resolve the play through the real engine, passing the chosen target via selectedCards.
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
var ap = new ActionProcessor(pair);
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: new List<BattleCardBase> { selected }),
"ActionProcessor.PlayCard threw on a targeted-destroy spell");
// Oracle: board removal is the new M7 dimension; the rest are the §5 spell-shaped invariants.
Assert.Multiple(() =>
{
// PRIMARY M7 assertions: the SELECTED follower is removed from the enemy board...
Assert.That(enemy.ClassAndInPlayCardList, Does.Not.Contain(selected),
"selected follower still on the enemy board (destroy did not remove it)");
Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore - 1),
"enemy board count not -1 (a destroy did not commit, or hit the wrong number of cards)");
// ...and it landed in the enemy's CemeteryList (the engine's destroy/death path).
Assert.That(enemy.CemeteryList, Contains.Item(selected),
"destroyed follower not in the enemy CemeteryList");
Assert.That(enemy.CemeteryList.Count, Is.EqualTo(enemyCemeteryBefore + 1),
"enemy cemetery count not +1");
// ...while the UN-SELECTED follower stays on the board (proves routing, not a board wipe).
Assert.That(enemy.ClassAndInPlayCardList, Contains.Item(unselected),
"un-selected follower was destroyed (effect not routed to the selection)");
// Leader untouched (destroy targets a follower, not the face).
Assert.That(enemy.ClassAndInPlayCardList[0].Life, Is.EqualTo(enemyLeaderLifeBefore),
"opponent leader life changed (destroy hit the leader, not the selected follower)");
// Cost paid.
Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost");
// Spell leaves hand and (being a spell) does NOT occupy the board.
Assert.That(player.HandCardList, Does.Not.Contain(card), "spell still in hand");
Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1");
Assert.That(player.ClassAndInPlayCardList, Does.Not.Contain(card), "spell wrongly placed on the board");
Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(playerInplayBefore), "player board count changed");
});
}
}
}

View File

@@ -0,0 +1,50 @@
#nullable enable
using System;
using System.Runtime.Serialization;
using SVSim.BattleEngine.Ambient;
namespace SVSim.BattleEngine.Tests;
/// <summary>Per-test ambient scope. Each test that touches engine statics wraps its body
/// in `using var scope = new TestBattleScope();` (or with an explicit Mgr/ViewerId).
///
/// The constructor enters a fresh <see cref="BattleAmbientContext"/> (carrying a brand-new
/// <see cref="GameMgr"/> so per-test mgr/DataMgr writes never bleed across tests), then
/// runs the per-ambient seeders that <see cref="HeadlessEngineEnv.EnsureProcessGlobals"/>
/// no longer does (chara ids on DataMgr, NetworkUserInfoData). Process-globals
/// (card master, LoadDetail, Crossover, Certification.udid) come from
/// <see cref="HeadlessEngineEnv.EnsureProcessGlobals"/> which runs once per process.
///
/// Public surface (vs. internal) so SVSim.UnitTests can reuse it via the same project
/// reference in Task 7.</summary>
public sealed class TestBattleScope : IDisposable
{
private readonly BattleAmbient.Scope _scope;
public BattleAmbientContext Ctx { get; }
public TestBattleScope(BattleManagerBase? mgr = null, int viewerId = 1001)
{
// Make sure process-globals are seeded before we enter; idempotent + cheap after first call.
HeadlessEngineEnv.EnsureProcessGlobals();
Ctx = new BattleAmbientContext
{
Mgr = mgr,
GameMgr = new GameMgr(),
ViewerId = viewerId,
IsForecast = true,
IsRandomDraw = true,
RecoveryInfo = (Wizard.BattleRecoveryInfo)FormatterServices
.GetUninitializedObject(typeof(Wizard.BattleRecoveryInfo)),
};
_scope = BattleAmbient.Enter(Ctx);
// Per-ambient seeders MUST run AFTER scope entry so GameMgr.GetIns() resolves to this
// scope's GameMgr (not a stray one). EnsureProcessGlobals used to do these writes against
// the global GameMgr; now they're scoped.
HeadlessEngineEnv.SeedCharaIdsOnCurrentAmbient();
HeadlessEngineEnv.SeedNetUserOnCurrentAmbient();
}
public void Dispose() => _scope.Dispose();
}

View File

@@ -0,0 +1,93 @@
using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
using Wizard;
using Wizard.Battle;
namespace SVSim.BattleEngine.Tests
{
// M2 first-green (go/no-go step 2): a single zero-skill vanilla follower resolves to correct
// authoritative state HEADLESS via the proven IsForecast/IsRecovery + ActionProcessor path
// (design §5 / DP4). No Unity runtime, no VFX clock.
[TestFixture]
public class VanillaFollowerOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
private static void SetPrivateField(object obj, string name, object value)
{
var f = obj.GetType().GetField(name,
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
// Walk up the hierarchy if declared on a base type.
var t = obj.GetType();
while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); }
Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}");
f.SetValue(obj, value);
}
[Test]
public void Vanilla_follower_resolves_to_correct_state()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
// Wire the opponent links + active turn. The full BattlePlayerBase.Setup(opponent) does
// this but cascades into UI/manager init irrelevant to the resolution path, so set the
// minimal state directly: each player's opponent ref, and the active player's turn flag
// (the on-enter-play skill sweep reads opponent.IsSelfTurn / IsGameFirst).
SetPrivateField(player, "_opponentBattlePlayer", enemy);
SetPrivateField(enemy, "_opponentBattlePlayer", player);
player.IsSelfTurn = true;
enemy.IsSelfTurn = false;
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.FollowerId);
// Place the follower in the active player's hand with PP to spare; empty board otherwise.
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.FollowerId, 1, isPlayer: true, mgr);
player.HandCardList.Add(card);
player.Pp = 10;
// Pre-state snapshot.
int ppBefore = player.Pp;
int handBefore = player.HandCardList.Count;
int inplayBefore = player.ClassAndInPlayCardList.Count;
int enemyHandBefore = enemy.HandCardList.Count;
int enemyInplayBefore = enemy.ClassAndInPlayCardList.Count;
int enemyLeaderLifeBefore = enemy.ClassAndInPlayCardList[0].Life;
// Resolve the play through the real engine.
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
var ap = new ActionProcessor(pair);
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null),
"ActionProcessor.PlayCard threw on a vanilla follower");
// Oracle (§5 invariants).
Assert.Multiple(() =>
{
Assert.That(player.Pp, Is.EqualTo(ppBefore - cardParam.Cost), "PP not reduced by exactly cost");
Assert.That(player.HandCardList, Does.Not.Contain(card), "card still in hand");
Assert.That(player.HandCardList.Count, Is.EqualTo(handBefore - 1), "hand count not -1");
Assert.That(player.ClassAndInPlayCardList, Contains.Item(card), "card not in play");
Assert.That(player.ClassAndInPlayCardList.Count, Is.EqualTo(inplayBefore + 1), "in-play count not +1");
Assert.That(card.Atk, Is.EqualTo(cardParam.Atk), "follower atk != CardCSVData base");
Assert.That(card.Life, Is.EqualTo(cardParam.Life), "follower life != CardCSVData base");
// Opponent unchanged.
Assert.That(enemy.HandCardList.Count, Is.EqualTo(enemyHandBefore), "opponent hand changed");
Assert.That(enemy.ClassAndInPlayCardList.Count, Is.EqualTo(enemyInplayBefore), "opponent board changed");
Assert.That(enemy.ClassAndInPlayCardList[0].Life, Is.EqualTo(enemyLeaderLifeBefore), "opponent leader life changed");
// §5 "zero VFX registered with VfxMgr": structural here — the shim VfxMgr is a pure
// no-op (RegisterImmediate/SequentialVfx do nothing) and IsForecast suppresses
// registration in the real engine, so no VFX is ever played headless. Covered by the
// DoesNotThrow above; there is no meaningful count to assert against the no-op shim.
});
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
# Verbatim engine copies: never normalize line endings (keeps sha256 manifest valid).
*.cs -text

View File

View File

@@ -0,0 +1,6 @@
public class AISendIntervalTrigger : SendIntervalTrigger
{
public override void SendDataCheck(NetworkBattleManagerBase networkBattleManager, NetworkBattleDefine.NetworkBattleURI sendUri)
{
}
}

View File

@@ -0,0 +1,58 @@
using System;
using Cute;
using Wizard;
public class AITurnControl
{
private DateTime _startTime;
private bool _isStartTimer;
public AITurnControl()
{
_isStartTimer = false;
}
public void StartTurnTimer()
{
_isStartTimer = true;
_startTime = TimeUtil.GetAbsoluteTime();
}
public void SetAndStartTurnTimer(DateTime time)
{
_isStartTimer = true;
_startTime = time;
}
public void Update(IEnemyAI ai)
{
if (ToolboxGame.RealTimeNetworkAgent != null && ToolboxGame.RealTimeNetworkAgent.PlayerNetworkStatus.IsAlive && !ai.IsConnectNetwork)
{
ai.Reconnect();
}
if (!_isStartTimer || !ToolboxGame.RealTimeNetworkAgent.PlayerNetworkStatus.IsAlive)
{
if (_isStartTimer && ai.IsConnectNetwork)
{
ai.Disconnect();
}
}
else if ((float)NetworkUtility.GetTimeSpanSecond(_startTime.Ticks) >= 90f)
{
if (ai.IsStackAction)
{
ai.CleanupStackedAction();
}
if (!ai.IsStackAction)
{
ai.TurnEnd();
}
}
}
public void StopTurnTimer()
{
_isStartTimer = false;
}
}

View File

@@ -0,0 +1,166 @@
using System.Collections.Generic;
using LitJson;
using Wizard;
using Wizard.Lottery;
public class AchievedInfo
{
private const string ACHIEVEMENT = "achieved_achievement_list";
private const string MISSION = "achieved_mission_list";
private const string REWARD = "achieved_mission_reward_list";
private const string VICTORY_REWARD = "win_reward_list";
private const string GRAND_MASTER_REWARD = "grand_master_reward_list";
private const string MISSION_START = "mission_start_data";
private const string BEGINNER_MISSION_REWARD = "achieved_beginner_mission_reward_list";
private const string BEGINNER_MISSION_REWARD_MESSAGE = "achieved_beginner_mission_list";
private const string BATTLE_PASS_REWARD_LIST = "battle_pass_reward_list";
private const string BATTLE_PASS_MESSAGE_LIST = "battle_pass_message_list";
private const long DONT_NOTIFY_IF_SMALLER_THAN_SECONDS = 10L;
public List<UserMission> _missions;
public List<UserAchievement> _achievements;
public List<ReceivedReward> _rewards;
public List<ReceivedReward> _victoryRewards;
public LotteryApplyData _lotteryData = LotteryApplyData.EmptyData();
public AchievedInfo()
{
_missions = new List<UserMission>();
_achievements = new List<UserAchievement>();
_rewards = new List<ReceivedReward>();
_victoryRewards = new List<ReceivedReward>();
}
public AchievedInfo(JsonData data)
: this()
{
Read(data);
}
public void Read(JsonData data)
{
if (data.Count == 0)
{
return;
}
if (data.Keys.Contains("achieved_mission_list"))
{
JsonData jsonData = data["achieved_mission_list"];
if (jsonData != null)
{
for (int i = 0; i < jsonData.Count; i++)
{
_missions.Add(UserMission.CreateAchievedMission(jsonData[i]));
}
}
}
if (data.Keys.Contains("achieved_achievement_list"))
{
JsonData jsonData2 = data["achieved_achievement_list"];
if (jsonData2 != null)
{
for (int j = 0; j < jsonData2.Count; j++)
{
UserAchievement userAchievement = UserAchievement.CreateCompletedAchievement(jsonData2[j]);
if (!string.IsNullOrEmpty(userAchievement.OsId))
{
AchievementImpl.instance.ReleaseAchievement(userAchievement.OsId);
}
_achievements.Add(userAchievement);
}
}
}
if (data.Keys.Contains("grand_master_reward_list"))
{
JsonData jsonData3 = data["grand_master_reward_list"];
if (jsonData3 != null)
{
for (int k = 0; k < jsonData3.Count; k++)
{
_rewards.Add(ReceivedReward.CreateFromBattleResultGrandMaster(jsonData3[k]));
}
}
}
if (data.Keys.Contains("achieved_mission_reward_list"))
{
JsonData jsonData4 = data["achieved_mission_reward_list"];
if (jsonData4 != null)
{
for (int l = 0; l < jsonData4.Count; l++)
{
_rewards.Add(ReceivedReward.CreateFromBattleResult(jsonData4[l]));
}
}
}
if (data.Keys.Contains("win_reward_list"))
{
JsonData jsonData5 = data["win_reward_list"];
if (jsonData5 != null)
{
for (int m = 0; m < jsonData5.Count; m++)
{
_victoryRewards.Add(ReceivedReward.CreateVictoryReward(jsonData5[m]));
}
}
}
if (data.Keys.Contains("achieved_beginner_mission_reward_list"))
{
JsonData jsonData6 = data["achieved_beginner_mission_reward_list"];
if (jsonData6 != null)
{
for (int n = 0; n < jsonData6.Count; n++)
{
_rewards.Add(ReceivedReward.CreateFromBeginnerMissionReward(jsonData6[n]));
}
}
}
if (data.Keys.Contains("achieved_beginner_mission_list"))
{
JsonData jsonData7 = data["achieved_beginner_mission_list"];
if (jsonData7 != null)
{
for (int num = 0; num < jsonData7.Count; num++)
{
_missions.Add(UserMission.CreateAchievedMission(jsonData7[num]));
}
}
}
if (data.Keys.Contains("battle_pass_reward_list"))
{
JsonData jsonData8 = data["battle_pass_reward_list"];
if (jsonData8 != null)
{
for (int num2 = 0; num2 < jsonData8.Count; num2++)
{
_rewards.Add(ReceivedReward.CreateFromBattlePassReward(jsonData8[num2]));
}
}
}
if (data.Keys.Contains("battle_pass_message_list"))
{
JsonData jsonData9 = data["battle_pass_message_list"];
if (jsonData9 != null)
{
for (int num3 = 0; num3 < jsonData9.Count; num3++)
{
_missions.Add(UserMission.CreateAchievedMission(jsonData9[num3]));
}
}
}
_lotteryData = LotteryApplyData.Parse(data);
}
}

View File

@@ -0,0 +1,4 @@
public class AchievementInfo : HeaderData
{
public AchievementInfoDetail data;
}

View File

@@ -0,0 +1,6 @@
using System.Collections.Generic;
public class AchievementInfoDetail
{
public List<UserAchievement> user_achievement_list;
}

View File

@@ -0,0 +1,777 @@
using System;
using System.Collections.Generic;
using Cute;
using UnityEngine;
using Wizard;
using Wizard.Bingo;
using Wizard.Lottery;
using Wizard.Scripts.Network.Data.TableData;
public class AchievementWindowBase : MonoBehaviour
{
public enum AchievementType
{
None,
Reward,
Nonattainment,
AlreadyReceived,
PointRunning,
PointClear,
PointReceived
}
public GameObject goButtonReward;
public UITexture achievementIconTexture;
public UILabel labelAchievementTitle;
public UILabel labelAchievementData;
public UILabel labelAchievementCount;
public UILabel _missionWaitLabel;
public UILabel _labelMissionPeriod;
public UILabel _labelMissionNotice;
public UISprite _titleLine;
public UILabel alreadyReceived;
[NonSerialized]
public int type;
[NonSerialized]
public int iType = -1;
[NonSerialized]
public int level;
[NonSerialized]
public string strAchievementData = "3種類のスリーブを使う。";
[SerializeField]
private UILabel LabelDetailBtn;
[SerializeField]
private UITable StarTable;
[SerializeField]
private UISprite StarOriginal;
[SerializeField]
private GameObject MailReceive;
[SerializeField]
private UIGauge GaugeUI;
[SerializeField]
private UILabel GaugeLabel;
[SerializeField]
private UISprite _Separator;
[SerializeField]
private UIWidget _StarsWidget;
[SerializeField]
private UILabel _labelTopRight;
[SerializeField]
private UILabel _missionStartTime;
[SerializeField]
private UILabel _missionTimeOver;
[SerializeField]
private UILabel _applyFinish;
private const int ACHIEVEMENT_STARS_MAX = 5;
private ResourceHandler _resourceHandler;
private const string SPRITE_PREFIX_BUTTON_BLUE = "btn_common_02_s_";
private int _viewMailId;
private QuestRewardInfo _questRewardInfo;
private Action _onReceivceAchievementSuccess;
private CrossoverRewardInfo _crossoverRewardInfo;
private const int BINGO_MISSION_SPRITE_WIDTH = 752;
private void Awake()
{
LabelDetailBtn.text = Data.SystemText.Get("Common_0022");
}
public void SetType(AchievementType typeBase)
{
labelAchievementCount.gameObject.SetActive(typeBase != AchievementType.AlreadyReceived);
alreadyReceived.gameObject.SetActive(typeBase == AchievementType.AlreadyReceived || typeBase == AchievementType.PointReceived);
goButtonReward.SetActive(typeBase != AchievementType.AlreadyReceived && typeBase != AchievementType.PointReceived);
UIManager.SetObjectToGrey(goButtonReward, typeBase == AchievementType.Nonattainment || typeBase == AchievementType.PointRunning);
}
public void SetActiveGaugeUI(bool isActive)
{
GaugeUI.gameObject.SetActive(isActive);
}
public void OnRewardClick()
{
AchievementReceiveRewardTask achievementReceiveRewardTask = new AchievementReceiveRewardTask();
achievementReceiveRewardTask.SetParameter(type, level);
StartCoroutine(Toolbox.NetworkManager.Connect(achievementReceiveRewardTask, OnRequestRewardAchievement, BaseTask.OnRequestFailed, BaseTask.OnFailedErrorCode));
}
public void OnDetail()
{
DialogBase dialogBase = UIManager.GetInstance().CreateDialogClose();
dialogBase.SetTitleLabel(Data.SystemText.Get("Mission_0007"));
dialogBase.SetText(strAchievementData);
dialogBase.SetButtonLayout(DialogBase.ButtonLayout.CloseBtn);
}
private void OnRequestRewardAchievement(NetworkTask.ResultCode error)
{
OnRequestReward(error, Data.MissionInfo.data.total_reward_list);
_onReceivceAchievementSuccess.Call();
}
private void OnRequestReward(NetworkTask.ResultCode error, List<ReceivedReward> rewards)
{
base.transform.parent.gameObject.AddMissingComponent<ReceiveReward>().ShowReadDialog(rewards, MailReceive, base.gameObject, _resourceHandler);
MyPageMenu.Instance.UpdateMissionCount();
}
private void SetAchievementCommon(UserAchievement achi)
{
bool num = achi.reward_type == 4;
strAchievementData = achi.achievement_name;
SystemText systemText = Data.SystemText;
labelAchievementTitle.text = systemText.Get("Mission_0023") + strAchievementData;
labelAchievementTitle.rightAnchor.target = _StarsWidget.transform;
labelAchievementTitle.rightAnchor.relative = 0f;
if (num)
{
ReceiveReward.SetTicket(achi.RewardUserGoodsId, achi.reward_number, achievementIconTexture, labelAchievementData, _resourceHandler);
}
else
{
ReceiveReward.SetTexture((UserGoods.Type)achi.reward_type, achievementIconTexture, _resourceHandler);
labelAchievementData.text = ReceiveReward.getTitle((UserGoods.Type)achi.reward_type, achi.RewardUserGoodsId, achi.reward_number);
}
GaugeUI.gameObject.SetActive(value: true);
int num2 = ((achi.total_count > achi.require_number) ? achi.require_number : achi.total_count);
int require_number = achi.require_number;
labelAchievementCount.gameObject.SetActive(value: false);
GaugeLabel.text = num2 + "/" + require_number;
if (num2 != 0 && require_number != 0)
{
float value = (float)num2 / (float)require_number;
GaugeUI.Value = value;
}
else
{
GaugeUI.Value = 0f;
}
goButtonReward.GetComponent<UIButton>().GetComponentInChildren<UILabel>().text = systemText.Get("Mail_0023");
}
private void SetRunning(UserAchievement achi)
{
SetType(AchievementType.Nonattainment);
SetAchievementCommon(achi);
SetAchievementStars(achi, cleared: false);
}
private void SetAchievementStars(UserAchievement achi, bool cleared)
{
int num = achi._maxLevel;
int num2 = achi.level;
if (achi._maxLevel > 5)
{
num = 5;
num2 = ((achi.level == achi._maxLevel) ? 5 : ((achi.level <= 0 || achi.level % 5 != 0) ? (achi.level % 5) : 5));
}
for (int i = 0; i < num; i++)
{
UISprite uISprite = UnityEngine.Object.Instantiate(StarOriginal, StarOriginal.transform.localPosition, StarOriginal.transform.localRotation);
uISprite.transform.parent = StarTable.transform;
uISprite.transform.localPosition = Vector3.zero;
uISprite.transform.localScale = Vector3.one;
if ((num2 == i + 1 && cleared) || num2 > i + 1)
{
uISprite.spriteName = "achievement_star_02";
}
else
{
uISprite.spriteName = "achievement_star_01";
}
uISprite.gameObject.SetActive(value: true);
}
StarTable.repositionNow = true;
}
private void SetCanReceive(UserAchievement achi)
{
SetType(AchievementType.Reward);
SetAchievementCommon(achi);
SetAchievementStars(achi, cleared: false);
UIButton component = goButtonReward.GetComponent<UIButton>();
component.onClick.Clear();
component.onClick.Add(new EventDelegate(delegate
{
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_COMMON_BUTTON);
OnRewardClick();
}));
}
private void SetAlreadyReceived(UserAchievement achi)
{
SetType(AchievementType.AlreadyReceived);
SetAchievementCommon(achi);
SetAchievementStars(achi, cleared: true);
}
public void SetAchievement(UserAchievement achi, ResourceHandler resourceHandler, Action onReceivceAchievementSuccess)
{
_resourceHandler = resourceHandler;
_onReceivceAchievementSuccess = onReceivceAchievementSuccess;
switch (achi.achievement_status)
{
case 0:
SetRunning(achi);
break;
case 1:
SetCanReceive(achi);
break;
case 2:
SetAlreadyReceived(achi);
break;
default:
UnityEngine.Debug.LogError("unkown achievement status");
break;
}
}
public void SetCrossoverReward(CrossoverRewardInfo reward, AchievementType type, ResourceHandler resourceHandler, Action onReceiveReward)
{
_crossoverRewardInfo = reward;
_resourceHandler = resourceHandler;
SetType(type);
string texName = UserGoods.GetUserGoodsImageName((UserGoods.Type)reward.RewardType, reward.RewardDetailId);
string assetTypePath = Toolbox.ResourcesManager.GetAssetTypePath(texName, ResourcesManager.AssetLoadPathType.Item);
_resourceHandler.Add(assetTypePath, delegate
{
if (reward.RewardDetailId == _crossoverRewardInfo.RewardDetailId)
{
string assetTypePath2 = Toolbox.ResourcesManager.GetAssetTypePath(texName, ResourcesManager.AssetLoadPathType.Item, isfetch: true);
achievementIconTexture.mainTexture = Toolbox.ResourcesManager.LoadObject<Texture>(assetTypePath2);
}
});
RankInfo rankInfo = Data.Load.data.GetRankInfo(Format.Crossover, reward.Rank);
labelAchievementTitle.text = Data.SystemText.Get("Profile_0042", Data.SystemText.Get(rankInfo.rank_name));
labelAchievementData.text = ReceiveReward.getTitle((UserGoods.Type)reward.RewardType, reward.RewardDetailId, reward.RewardCount);
GaugeUI.gameObject.SetActive(value: false);
UIButton component = goButtonReward.GetComponent<UIButton>();
component.GetComponentInChildren<UILabel>().text = Data.SystemText.Get("Mail_0023");
goButtonReward.gameObject.SetActive(type != AchievementType.PointReceived);
UIManager.SetObjectToGrey(goButtonReward, type != AchievementType.PointClear);
component.onClick.Clear();
component.onClick.Add(new EventDelegate(delegate
{
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_COMMON_BUTTON);
ReceiveCrossoverReward(reward.RewardId, onReceiveReward);
}));
CopyAnchor(_labelTopRight.rightAnchor, labelAchievementTitle.rightAnchor);
}
private void ReceiveCrossoverReward(int rewardId, Action onReceiveReward)
{
CrossoverReceiveRankRewardTask task = new CrossoverReceiveRankRewardTask();
task.SetParameter(rewardId);
StartCoroutine(Toolbox.NetworkManager.Connect(task, delegate
{
onReceiveReward.Call();
DialogCreator.CreateRewardReceiveDialog(task.ReceivedRewardList);
}));
}
public void SetQuestPoint(QuestRewardInfo reward, AchievementType type, ResourceHandler resourceHandler, Action onRequestRewardPointCallBack)
{
_questRewardInfo = reward;
strAchievementData = reward.Point.ToString();
labelAchievementTitle.text = Data.SystemText.Get("Quest_0019", strAchievementData);
_resourceHandler = resourceHandler;
SetType(type);
string texName = UserGoods.GetUserGoodsImageName((UserGoods.Type)reward.RewardType, reward.RewardDetailId);
string assetTypePath = Toolbox.ResourcesManager.GetAssetTypePath(texName, ResourcesManager.AssetLoadPathType.Item);
_resourceHandler.Add(assetTypePath, delegate
{
if (reward.RewardDetailId == _questRewardInfo.RewardDetailId)
{
string assetTypePath2 = Toolbox.ResourcesManager.GetAssetTypePath(texName, ResourcesManager.AssetLoadPathType.Item, isfetch: true);
achievementIconTexture.mainTexture = Toolbox.ResourcesManager.LoadObject<Texture>(assetTypePath2);
}
});
labelAchievementData.text = ReceiveReward.getTitle((UserGoods.Type)reward.RewardType, reward.RewardDetailId, reward.RewardCount);
GaugeUI.gameObject.SetActive(value: false);
UIButton component = goButtonReward.GetComponent<UIButton>();
component.GetComponentInChildren<UILabel>().text = Data.SystemText.Get("Mail_0023");
goButtonReward.gameObject.SetActive(type != AchievementType.PointReceived);
UIManager.SetObjectToGrey(goButtonReward, type != AchievementType.PointClear);
component.onClick.Clear();
component.onClick.Add(new EventDelegate(delegate
{
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_COMMON_BUTTON);
OnQuestPointReceive(reward.Id, onRequestRewardPointCallBack);
}));
GetComponent<UISprite>().spriteName = string.Empty;
CopyAnchor(_labelTopRight.rightAnchor, labelAchievementTitle.rightAnchor);
}
public void SetMission(UserMission mission, ResourceHandler resourceHandler, bool canChangeMissions, bool enableSeparator, bool displayChange, Action onChangeMissionSuccess = null)
{
_resourceHandler = resourceHandler;
_Separator.gameObject.SetActive(enableSeparator);
if (mission.mission_status == 0 && SetMissionWait(mission))
{
return;
}
SystemText systemText = Data.SystemText;
if (mission.reward_type == 4)
{
ReceiveReward.SetTicket(mission.RewardUserGoodsId, mission.reward_number, achievementIconTexture, labelAchievementData, _resourceHandler);
}
else
{
ReceiveReward.SetTexture((UserGoods.Type)mission.reward_type, mission.RewardUserGoodsId, achievementIconTexture, _resourceHandler);
labelAchievementData.text = ReceiveReward.getTitle((UserGoods.Type)mission.reward_type, mission.RewardUserGoodsId, mission.reward_number);
}
labelAchievementTitle.text = mission.mission_name;
int require_number = mission.require_number;
bool flag = require_number > 0;
GaugeUI.gameObject.SetActive(flag);
if (flag)
{
int num = ((mission.total_count > mission.require_number) ? mission.require_number : mission.total_count);
GaugeLabel.text = num + "/" + require_number;
if (num != 0)
{
float value = (float)num / (float)require_number;
GaugeUI.Value = value;
}
else
{
GaugeUI.Value = 0f;
}
}
UIButton component = goButtonReward.GetComponent<UIButton>();
component.normalSprite = "btn_common_02_s_off";
component.pressedSprite = "btn_common_02_s_on";
component.GetComponentInChildren<UILabel>().text = systemText.Get("Mission_0029");
component.onClick.Add(new EventDelegate(delegate
{
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_COMMON_BUTTON);
ChangeMission(mission.id, mission.mission_name, onChangeMissionSuccess);
}));
goButtonReward.SetActive(displayChange && !mission.default_flag);
UIManager.SetObjectToGrey(goButtonReward, !canChangeMissions);
labelAchievementCount.gameObject.SetActive(value: false);
SetMissionPeriodLabel(mission);
CopyAnchor(_labelTopRight.rightAnchor, labelAchievementTitle.rightAnchor);
}
public void SetBttlePassMonthlyMission(BattlePassMonthlyMission.MissionDetail mission, ResourceHandler resourceHandler)
{
_Separator.gameObject.SetActive(value: false);
_labelMissionPeriod.gameObject.SetActive(value: false);
labelAchievementCount.gameObject.SetActive(value: false);
goButtonReward.gameObject.SetActive(value: false);
_resourceHandler = resourceHandler;
labelAchievementTitle.text = mission.Name;
alreadyReceived.gameObject.SetActive(mission.IsCleared);
BattlePassMonthlyMission.MissionDetail.RewardInfo reward = mission.Reward;
if (reward == null)
{
_resourceHandler.Add(Toolbox.ResourcesManager.GetAssetTypePath("thumbnail_battle_pass_point", ResourcesManager.AssetLoadPathType.BattlePass), delegate
{
string assetTypePath = Toolbox.ResourcesManager.GetAssetTypePath("thumbnail_battle_pass_point", ResourcesManager.AssetLoadPathType.BattlePass, isfetch: true);
achievementIconTexture.mainTexture = Toolbox.ResourcesManager.LoadObject<Texture>(assetTypePath);
});
labelAchievementData.text = string.Empty;
}
else if (reward.UserGoods.GoodsType == UserGoods.Type.Item)
{
ReceiveReward.SetTicket(reward.UserGoods.Id, reward.Number, achievementIconTexture, labelAchievementData, _resourceHandler);
}
else
{
ReceiveReward.SetTexture(reward.UserGoods.GoodsType, achievementIconTexture, _resourceHandler);
labelAchievementData.text = ReceiveReward.getTitle(reward.UserGoods.GoodsType, reward.UserGoods.Id, reward.Number);
}
SetViewBattlePassPointText(mission.BattlePassPoint);
int requireNumber = mission.RequireNumber;
bool flag = requireNumber > 0;
GaugeUI.gameObject.SetActive(flag);
if (flag)
{
int num = ((mission.DoneNumber > mission.RequireNumber) ? mission.RequireNumber : mission.DoneNumber);
GaugeLabel.text = num + "/" + requireNumber;
if (num != 0)
{
float value = (float)num / (float)requireNumber;
GaugeUI.Value = value;
}
else
{
GaugeUI.Value = 0f;
}
}
}
private void SetViewBattlePassPointText(int point)
{
string text = " ";
if (labelAchievementData.text == string.Empty)
{
labelAchievementData.text = Data.SystemText.Get("BattlePass_0010", point.ToString());
}
else
{
UILabel uILabel = labelAchievementData;
uILabel.text = uILabel.text + text + Data.SystemText.Get("BattlePass_0010", point.ToString());
}
}
private bool SetMissionWait(UserMission mission)
{
MissionInfoTask missionInfoTask = GameMgr.GetIns().GetMissionInfoTask();
long num = (long)Time.realtimeSinceStartup - missionInfoTask.RequestTime;
long num2 = missionInfoTask.ServerTime + num;
TimeSpan timeSpan = TimeSpan.FromSeconds(mission.start_time - num2).Add(new TimeSpan(0, 1, 0));
int num3 = timeSpan.Hours;
int num4 = timeSpan.Minutes;
if (timeSpan.TotalHours >= 24.0)
{
num3 = 24;
num4 = 0;
}
else if (num3 <= 0 && num4 <= 0)
{
return false;
}
if (mission.IsGemMission())
{
_missionWaitLabel.text = Data.SystemText.Get("Mission_0073", num3.ToString("00"), num4.ToString("00"));
_labelMissionNotice.gameObject.SetActive(value: true);
_labelMissionNotice.text = Data.SystemText.Get("Mission_0074");
}
else
{
_missionWaitLabel.text = Data.SystemText.Get("Mission_0041", num3.ToString("00"), num4.ToString("00"));
}
labelAchievementTitle.gameObject.SetActive(value: false);
labelAchievementCount.gameObject.SetActive(value: false);
labelAchievementData.gameObject.SetActive(value: false);
labelAchievementData.gameObject.SetActive(value: false);
goButtonReward.gameObject.SetActive(value: false);
GaugeUI.gameObject.SetActive(value: false);
_titleLine.gameObject.SetActive(value: false);
_missionWaitLabel.gameObject.SetActive(value: true);
return true;
}
private void SetMissionPeriodLabel(UserMission mission)
{
if (mission.end_time <= 0 || mission.IsGemMission())
{
_labelMissionPeriod.gameObject.SetActive(value: false);
return;
}
long nowUnixTime = GameMgr.GetIns().GetMissionInfoTask().NowUnixTime();
string remainingTime = ConvertTime.GetRemainingTime(TimeSpan.FromSeconds(mission.GetMissionPeriodSec(nowUnixTime)));
goButtonReward.gameObject.SetActive(value: false);
_labelMissionPeriod.gameObject.SetActive(value: true);
_labelMissionPeriod.text = remainingTime;
}
private void ChangeMission(int id, string content, Action onChangeMissionSuccess)
{
SystemText systemText = Data.SystemText;
DialogBase dialogBase = UIManager.GetInstance().CreateDialogClose();
dialogBase.SetTitleLabel(systemText.Get("Mission_0033"));
dialogBase.SetText(systemText.Get("Mission_0030", content));
dialogBase.SetButtonLayout(DialogBase.ButtonLayout.DecisionBtn);
dialogBase.onPushButton1 = delegate
{
MissionRetireTask missionRetireTask = new MissionRetireTask();
missionRetireTask.SetParameter(id);
StartCoroutine(Toolbox.NetworkManager.Connect(missionRetireTask, delegate
{
onChangeMissionSuccess.Call();
}, BaseTask.OnRequestFailed, BaseTask.OnFailedErrorCode));
};
}
private void OnQuestPointReceive(int rewardId, Action onRequestRewardPointCallBack)
{
QuestRewardReceiveTask task = new QuestRewardReceiveTask();
task.SetParameter(rewardId);
StartCoroutine(Toolbox.NetworkManager.Connect(task, delegate
{
onRequestRewardPointCallBack.Call();
DialogCreator.CreateRewardReceiveDialog(task.ReceiveRewardList);
}, BaseTask.OnRequestFailed, BaseTask.OnFailedErrorCode));
}
public void SetHistoryItem(ItemAcquireHistory item, bool enableSeparator, ResourceHandler resourceHandler)
{
_resourceHandler = resourceHandler;
SystemText systemText = Data.SystemText;
if (item.RewardType == 4)
{
ReceiveReward.SetTicket(item.RewardUserGoodsId, item.RewardCount, achievementIconTexture, labelAchievementTitle, _resourceHandler);
}
else
{
ReceiveReward.SetTexture((UserGoods.Type)item.RewardType, achievementIconTexture, _resourceHandler);
labelAchievementTitle.text = ReceiveReward.getTitle((UserGoods.Type)item.RewardType, item.RewardUserGoodsId, item.RewardCount);
}
labelAchievementData.text = item.Message;
labelAchievementCount.text = systemText.Get("Mail_0043", ConvertTime.ToLocal(item.AcquireTime));
GaugeUI.gameObject.SetActive(value: false);
goButtonReward.SetActive(value: false);
_Separator.gameObject.SetActive(enableSeparator);
}
public void SetMail(MailData mail, Action<int, int> OnReadMail, ResourceHandler handler)
{
_resourceHandler = handler;
_viewMailId = mail.mail_id;
SetCommonMail(mail);
TimeLeftUpdate timeLeftUpdate = base.gameObject.AddMissingComponent<TimeLeftUpdate>();
timeLeftUpdate.mailData = mail;
_labelTopRight.gameObject.SetActive(value: true);
timeLeftUpdate.timeLeft = _labelTopRight;
timeLeftUpdate.UpdateTime();
SystemText systemText = Data.SystemText;
labelAchievementCount.text = systemText.Get("Mail_0043", mail.create_time);
goButtonReward.SetActive(value: true);
goButtonReward.transform.Find("RewardLabel").GetComponent<UILabel>().text = systemText.Get("Mail_0023");
UIButton component = goButtonReward.GetComponent<UIButton>();
component.normalSprite = "btn_common_02_s_off";
component.hoverSprite = "btn_common_02_s_off";
component.pressedSprite = "btn_common_02_s_on";
UIEventListener.Get(goButtonReward).onClick = delegate
{
OnReadMail(mail.mail_id, mail.mail_id);
};
}
public void SetHistoryMail(MailData mail, ResourceHandler handler)
{
_resourceHandler = handler;
_viewMailId = mail.mail_id;
SetCommonMail(mail);
TimeLeftUpdate component = base.gameObject.GetComponent<TimeLeftUpdate>();
if ((bool)component)
{
component.mailData = null;
}
_labelTopRight.gameObject.SetActive(value: false);
labelAchievementCount.text = Data.SystemText.Get("Mail_0044", mail.create_time);
goButtonReward.SetActive(value: false);
}
private void SetCommonMail(MailData mailData)
{
GaugeUI.gameObject.SetActive(value: false);
labelAchievementData.text = mailData.message;
labelAchievementTitle.text = ReceiveReward.getTitle(mailData);
string textureName = ReceiveReward.GetThumbnailName((UserGoods.Type)mailData.reward_type, mailData.RewardUserGoodsId);
string assetTypePath = Toolbox.ResourcesManager.GetAssetTypePath(textureName, ResourcesManager.AssetLoadPathType.Item);
_resourceHandler.Add(assetTypePath, delegate
{
if (mailData.mail_id == _viewMailId)
{
string assetTypePath2 = Toolbox.ResourcesManager.GetAssetTypePath(textureName, ResourcesManager.AssetLoadPathType.Item, isfetch: true);
achievementIconTexture.mainTexture = Toolbox.ResourcesManager.LoadObject<Texture>(assetTypePath2);
}
});
}
private void CopyAnchor(UIRect.AnchorPoint original, UIRect.AnchorPoint destination)
{
destination.target = original.target;
destination.relative = original.relative;
destination.absolute = original.absolute;
}
public void SetGetButtonToGreyOut()
{
UIManager.SetObjectToGrey(goButtonReward, b: true);
}
public void SetLottery(LotteryMissionData lotteryData, bool needCeparator)
{
string userGoodsImageName = UserGoods.GetUserGoodsImageName(lotteryData.UserGoodsType, lotteryData.ItemId);
string assetTypePath = Toolbox.ResourcesManager.GetAssetTypePath(userGoodsImageName, ResourcesManager.AssetLoadPathType.Item, isfetch: true);
base.gameObject.GetComponent<UISprite>().width = 800;
achievementIconTexture.mainTexture = Toolbox.ResourcesManager.LoadObject<Texture>(assetTypePath);
_Separator.gameObject.SetActive(needCeparator);
labelAchievementTitle.text = lotteryData.MissionTitle;
if (lotteryData.UserGoodsType == UserGoods.Type.Item)
{
labelAchievementData.text = ReceiveReward.SetTicketTitle(lotteryData.ItemId, lotteryData.ItemCount);
}
else
{
labelAchievementData.text = ReceiveReward.getTitle(lotteryData.UserGoodsType, lotteryData.ItemId, lotteryData.ItemCount);
}
goButtonReward.SetActive(value: false);
if (lotteryData.StartTime.Second > 0)
{
_missionStartTime.text = Data.SystemText.Get("Mission_0077", lotteryData.StartTime.LocalTime);
_missionStartTime.gameObject.SetActive(value: true);
}
else
{
_labelMissionPeriod.text = lotteryData.EndTime.GetShowText("Mission_0062", "Mission_0060", "Mission_0061");
_labelMissionPeriod.gameObject.SetActive(value: true);
}
CopyAnchor(_labelTopRight.rightAnchor, labelAchievementTitle.rightAnchor);
_applyFinish.gameObject.SetActive(lotteryData.IsCleared);
_labelMissionPeriod.gameObject.SetActive(!lotteryData.IsCleared && !lotteryData.IsTimeOver);
_missionTimeOver.gameObject.SetActive(lotteryData.IsTimeOver);
GaugeLabel.text = lotteryData.MissionCurrent + "/" + lotteryData.MissionMax;
GaugeUI.Value = lotteryData.MissionRatio;
bool active = true;
if (lotteryData.IsCleared || lotteryData.MissionMax == 0)
{
active = false;
}
GaugeUI.gameObject.SetActive(active);
}
public void SetBingoMission(BingoInfoTask.BingoMissionData missionData, bool needCeparator, ResourceHandler handler)
{
_resourceHandler = handler;
base.gameObject.GetComponent<UISprite>().width = 752;
base.gameObject.GetComponent<UISprite>().enabled = false;
alreadyReceived.gameObject.SetActive(missionData.IsCleared);
labelAchievementData.text = ReceiveReward.getTitle((UserGoods.Type)missionData.Reward.reward_type, missionData.Reward.rewardUserGoodsId, missionData.Reward.reward_count);
string textureName = UserGoods.GetUserGoodsImageName((UserGoods.Type)missionData.Reward.reward_type, missionData.Reward.rewardUserGoodsId);
string assetTypePath = Toolbox.ResourcesManager.GetAssetTypePath(textureName, ResourcesManager.AssetLoadPathType.Item);
_resourceHandler.Add(assetTypePath, delegate
{
string assetTypePath2 = Toolbox.ResourcesManager.GetAssetTypePath(textureName, ResourcesManager.AssetLoadPathType.Item, isfetch: true);
achievementIconTexture.mainTexture = Toolbox.ResourcesManager.LoadObject<Texture>(assetTypePath2);
});
CopyAnchor(_labelTopRight.rightAnchor, labelAchievementTitle.rightAnchor);
_titleLine.SetAnchor((GameObject)null);
_titleLine.spriteName = "quest_line_05";
_titleLine.SetDimensions(610, 2);
_Separator.spriteName = "quest_line_02";
_Separator.gameObject.SetActive(needCeparator);
goButtonReward.SetActive(value: false);
GaugeLabel.text = missionData.MissionCurrent + "/" + missionData.MissionMax;
GaugeUI.Value = missionData.MissionRatio;
labelAchievementTitle.text = missionData.MissionTitle;
}
public void SetBingoRewardDetails(ReceivedReward reward, bool needCeparator, ResourceHandler handler)
{
_resourceHandler = handler;
base.gameObject.GetComponent<UISprite>().width = 752;
base.gameObject.GetComponent<UISprite>().enabled = false;
goButtonReward.SetActive(value: false);
_titleLine.SetAnchor((GameObject)null);
_titleLine.spriteName = "quest_line_05";
_titleLine.SetDimensions(610, 2);
_Separator.spriteName = "quest_line_02";
_Separator.gameObject.SetActive(needCeparator);
labelAchievementTitle.text = string.Format(Data.SystemText.Get("Bingo_0004", reward.lineNum.ToString()));
labelAchievementData.text = ReceiveReward.getTitle((UserGoods.Type)reward.reward_type, reward.rewardUserGoodsId, reward.reward_count);
GaugeUI.gameObject.SetActive(value: false);
string textureName = UserGoods.GetUserGoodsImageName((UserGoods.Type)reward.reward_type, reward.rewardUserGoodsId);
string assetTypePath = Toolbox.ResourcesManager.GetAssetTypePath(textureName, ResourcesManager.AssetLoadPathType.Item);
_resourceHandler.Add(assetTypePath, delegate
{
string assetTypePath2 = Toolbox.ResourcesManager.GetAssetTypePath(textureName, ResourcesManager.AssetLoadPathType.Item, isfetch: true);
achievementIconTexture.mainTexture = Toolbox.ResourcesManager.LoadObject<Texture>(assetTypePath2);
});
}
public void SetBingoSideBarRewards(string lineNum, ReceivedReward reward, bool isCleared, bool needCeparator, ResourceHandler handler)
{
_resourceHandler = handler;
_Separator.gameObject.SetActive(needCeparator);
goButtonReward.SetActive(value: false);
labelAchievementTitle.text = string.Format(Data.SystemText.Get("Bingo_0004", lineNum));
labelAchievementData.text = ReceiveReward.getTitle((UserGoods.Type)reward.reward_type, reward.rewardUserGoodsId, reward.reward_count);
alreadyReceived.gameObject.SetActive(isCleared);
string textureName = UserGoods.GetUserGoodsImageName((UserGoods.Type)reward.reward_type, reward.rewardUserGoodsId);
string assetTypePath = Toolbox.ResourcesManager.GetAssetTypePath(textureName, ResourcesManager.AssetLoadPathType.Item);
_resourceHandler.Add(assetTypePath, delegate
{
string assetTypePath2 = Toolbox.ResourcesManager.GetAssetTypePath(textureName, ResourcesManager.AssetLoadPathType.Item, isfetch: true);
achievementIconTexture.mainTexture = Toolbox.ResourcesManager.LoadObject<Texture>(assetTypePath2);
});
}
public void SetPracticePuzzleMission(PracticePuzzleMissionData mission, ResourceHandler resourceHandler, bool canChangeMissions, bool enableSeparator, bool displayChange, Action onChangeMissionSuccess = null)
{
_resourceHandler = resourceHandler;
_Separator.gameObject.SetActive(enableSeparator);
goButtonReward.SetActive(value: false);
_ = Data.SystemText;
if (mission.UserGoodsType == UserGoods.Type.Item)
{
ReceiveReward.SetTicket(mission.ItemId, mission.ItemCount, achievementIconTexture, labelAchievementData, _resourceHandler);
}
else
{
ReceiveReward.SetTexture(mission.UserGoodsType, mission.ItemId, achievementIconTexture, _resourceHandler);
labelAchievementData.text = ReceiveReward.getTitle(mission.UserGoodsType, mission.ItemId, mission.ItemCount);
}
labelAchievementTitle.text = mission.Name;
int totalMissionCount = mission.TotalMissionCount;
bool flag = totalMissionCount > 0;
GaugeUI.gameObject.SetActive(flag);
if (flag)
{
int num = ((mission.TotalMissionCount > mission.CurrentClearCount) ? mission.CurrentClearCount : mission.TotalMissionCount);
GaugeLabel.text = num + "/" + totalMissionCount;
if (num != 0)
{
float value = (float)num / (float)totalMissionCount;
GaugeUI.Value = value;
}
else
{
GaugeUI.Value = 0f;
}
}
alreadyReceived.gameObject.SetActive(mission.IsCleared);
labelAchievementCount.gameObject.SetActive(value: false);
CopyAnchor(_labelTopRight.rightAnchor, labelAchievementTitle.rightAnchor);
}
public void SetRedEtherMission(RedEtherCampaignRewardData rewardData, ResourceHandler resourceHandler)
{
_resourceHandler = resourceHandler;
goButtonReward.SetActive(value: false);
ReceiveReward.SetTexture(rewardData.UserGoodsType, 0L, achievementIconTexture, _resourceHandler);
labelAchievementData.text = ReceiveReward.getTitle(rewardData.UserGoodsType, 0L, rewardData.ItemCount);
labelAchievementTitle.text = rewardData.MissionText;
alreadyReceived.gameObject.SetActive(rewardData.IsCleared);
GaugeUI.gameObject.SetActive(value: false);
}
}

View File

@@ -0,0 +1,356 @@
using System.Collections.Generic;
using AnimationOrTween;
using UnityEngine;
[AddComponentMenu("NGUI/Internal/Active Animation")]
public class ActiveAnimation : MonoBehaviour
{
public static ActiveAnimation current;
public List<EventDelegate> onFinished = new List<EventDelegate>();
[HideInInspector]
public GameObject eventReceiver;
[HideInInspector]
public string callWhenFinished;
private Animation mAnim;
private Direction mLastDirection;
private Direction mDisableDirection;
private bool mNotify;
private Animator mAnimator;
private string mClip = "";
private float playbackTime => Mathf.Clamp01(mAnimator.GetCurrentAnimatorStateInfo(0).normalizedTime);
public bool isPlaying
{
get
{
if (mAnim == null)
{
if (mAnimator != null)
{
if (mLastDirection == Direction.Reverse)
{
if (playbackTime == 0f)
{
return false;
}
}
else if (playbackTime == 1f)
{
return false;
}
return true;
}
return false;
}
foreach (AnimationState item in mAnim)
{
if (!mAnim.IsPlaying(item.name))
{
continue;
}
if (mLastDirection == Direction.Forward)
{
if (item.time < item.length)
{
return true;
}
continue;
}
if (mLastDirection == Direction.Reverse)
{
if (item.time > 0f)
{
return true;
}
continue;
}
return true;
}
return false;
}
}
public void Finish()
{
if (mAnim != null)
{
foreach (AnimationState item in mAnim)
{
if (mLastDirection == Direction.Forward)
{
item.time = item.length;
}
else if (mLastDirection == Direction.Reverse)
{
item.time = 0f;
}
}
mAnim.Sample();
}
else if (mAnimator != null)
{
mAnimator.Play(mClip, 0, (mLastDirection == Direction.Forward) ? 1f : 0f);
}
}
public void Reset()
{
if (mAnim != null)
{
foreach (AnimationState item in mAnim)
{
if (mLastDirection == Direction.Reverse)
{
item.time = item.length;
}
else if (mLastDirection == Direction.Forward)
{
item.time = 0f;
}
}
return;
}
if (mAnimator != null)
{
mAnimator.Play(mClip, 0, (mLastDirection == Direction.Reverse) ? 1f : 0f);
}
}
private void Start()
{
if (eventReceiver != null && EventDelegate.IsValid(onFinished))
{
eventReceiver = null;
callWhenFinished = null;
}
}
private void Update()
{
float deltaTime = RealTime.deltaTime;
if (deltaTime == 0f)
{
return;
}
if (mAnimator != null)
{
mAnimator.Update((mLastDirection == Direction.Reverse) ? (0f - deltaTime) : deltaTime);
if (isPlaying)
{
return;
}
mAnimator.enabled = false;
base.enabled = false;
}
else
{
if (!(mAnim != null))
{
base.enabled = false;
return;
}
bool flag = false;
foreach (AnimationState item in mAnim)
{
if (!mAnim.IsPlaying(item.name))
{
continue;
}
float num = item.speed * deltaTime;
item.time += num;
if (num < 0f)
{
if (item.time > 0f)
{
flag = true;
}
else
{
item.time = 0f;
}
}
else if (item.time < item.length)
{
flag = true;
}
else
{
item.time = item.length;
}
}
mAnim.Sample();
if (flag)
{
return;
}
base.enabled = false;
}
if (!mNotify)
{
return;
}
mNotify = false;
if (current == null)
{
current = this;
EventDelegate.Execute(onFinished);
if (eventReceiver != null && !string.IsNullOrEmpty(callWhenFinished))
{
eventReceiver.SendMessage(callWhenFinished, SendMessageOptions.DontRequireReceiver);
}
current = null;
}
if (mDisableDirection != Direction.Toggle && mLastDirection == mDisableDirection)
{
NGUITools.SetActive(base.gameObject, state: false);
}
}
private void Play(string clipName, Direction playDirection)
{
if (playDirection == Direction.Toggle)
{
playDirection = ((mLastDirection != Direction.Forward) ? Direction.Forward : Direction.Reverse);
}
if (mAnim != null)
{
base.enabled = true;
mAnim.enabled = false;
if (string.IsNullOrEmpty(clipName))
{
if (!mAnim.isPlaying)
{
mAnim.Play();
}
}
else if (!mAnim.IsPlaying(clipName))
{
mAnim.Play(clipName);
}
foreach (AnimationState item in mAnim)
{
if (string.IsNullOrEmpty(clipName) || item.name == clipName)
{
float num = Mathf.Abs(item.speed);
item.speed = num * (float)playDirection;
if (playDirection == Direction.Reverse && item.time == 0f)
{
item.time = item.length;
}
else if (playDirection == Direction.Forward && item.time == item.length)
{
item.time = 0f;
}
}
}
mLastDirection = playDirection;
mNotify = true;
mAnim.Sample();
}
else if (mAnimator != null)
{
if (base.enabled && isPlaying && mClip == clipName)
{
mLastDirection = playDirection;
return;
}
base.enabled = true;
mNotify = true;
mLastDirection = playDirection;
mClip = clipName;
mAnimator.Play(mClip, 0, (playDirection == Direction.Forward) ? 0f : 1f);
}
}
public static ActiveAnimation Play(Animation anim, string clipName, Direction playDirection, EnableCondition enableBeforePlay, DisableCondition disableCondition)
{
if (!NGUITools.GetActive(anim.gameObject))
{
if (enableBeforePlay != EnableCondition.EnableThenPlay)
{
return null;
}
NGUITools.SetActive(anim.gameObject, state: true);
UIPanel[] componentsInChildren = anim.gameObject.GetComponentsInChildren<UIPanel>();
int i = 0;
for (int num = componentsInChildren.Length; i < num; i++)
{
componentsInChildren[i].Refresh();
}
}
ActiveAnimation activeAnimation = anim.GetComponent<ActiveAnimation>();
if (activeAnimation == null)
{
activeAnimation = anim.gameObject.AddComponent<ActiveAnimation>();
}
activeAnimation.mAnim = anim;
activeAnimation.mDisableDirection = (Direction)disableCondition;
activeAnimation.onFinished.Clear();
activeAnimation.Play(clipName, playDirection);
if (activeAnimation.mAnim != null)
{
activeAnimation.mAnim.Sample();
}
else if (activeAnimation.mAnimator != null)
{
activeAnimation.mAnimator.Update(0f);
}
return activeAnimation;
}
public static ActiveAnimation Play(Animation anim, string clipName, Direction playDirection)
{
return Play(anim, clipName, playDirection, EnableCondition.DoNothing, DisableCondition.DoNotDisable);
}
public static ActiveAnimation Play(Animation anim, Direction playDirection)
{
return Play(anim, null, playDirection, EnableCondition.DoNothing, DisableCondition.DoNotDisable);
}
public static ActiveAnimation Play(Animator anim, string clipName, Direction playDirection, EnableCondition enableBeforePlay, DisableCondition disableCondition)
{
if (enableBeforePlay != EnableCondition.IgnoreDisabledState && !NGUITools.GetActive(anim.gameObject))
{
if (enableBeforePlay != EnableCondition.EnableThenPlay)
{
return null;
}
NGUITools.SetActive(anim.gameObject, state: true);
UIPanel[] componentsInChildren = anim.gameObject.GetComponentsInChildren<UIPanel>();
int i = 0;
for (int num = componentsInChildren.Length; i < num; i++)
{
componentsInChildren[i].Refresh();
}
}
ActiveAnimation activeAnimation = anim.GetComponent<ActiveAnimation>();
if (activeAnimation == null)
{
activeAnimation = anim.gameObject.AddComponent<ActiveAnimation>();
}
activeAnimation.mAnimator = anim;
activeAnimation.mDisableDirection = (Direction)disableCondition;
activeAnimation.onFinished.Clear();
activeAnimation.Play(clipName, playDirection);
if (activeAnimation.mAnim != null)
{
activeAnimation.mAnim.Sample();
}
else if (activeAnimation.mAnimator != null)
{
activeAnimation.mAnimator.Update(0f);
}
return activeAnimation;
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
public class AddDamageInfo : DamageModifier
{
public int AddDamage { get; protected set; }
public AddDamageInfo(int addDamage, string damageType, CardBasePrm.ClanType damageClan, bool isUseClass, int order)
{
AddDamage = addDamage;
base.DamageType = new List<string>();
base.DamageType.AddRange(damageType.Split(new string[1] { "_and_" }, StringSplitOptions.None));
base.DamageClan = new List<CardBasePrm.ClanType> { damageClan };
base.IsUseClass = isUseClass;
base.OrderCount = order;
}
public override int Calc(int damage)
{
return damage + AddDamage;
}
}

View File

@@ -0,0 +1,20 @@
public class AddHealModifierInfo : HealModifier
{
public int AddHealAmount { get; private set; }
public AddHealModifierInfo(int addHealAmount, int order, BattleCardBase owner)
{
AddHealAmount = addHealAmount;
base.OrderCount = order;
_owner = owner;
}
public override int Calc(int healAmount, BattleCardBase healOwner, BattleCardBase target)
{
if (healOwner.IsPlayer != _owner.IsPlayer)
{
return healAmount;
}
return healAmount + AddHealAmount;
}
}

View File

@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Wizard;
public class AddTargetInfo
{
private BattleCardBase _ownerCard;
private ConditionSkillFilterCollection _conditionFilter;
private ApplySkillTargetFilterCollection _targetFilter;
private Func<SkillBase, bool> typeCheck;
private string _conditionFilterText;
private string _targetFilterText;
private string _skillTypeText;
private string _ownerCardtype;
private SkillCreator _skillCreator;
public AddTargetInfo(BattleCardBase ownerCard, string conditionFilterText, string targetFilterText, string skillTypeText, string ownerCardType, SkillBase skill)
{
_ownerCard = ownerCard;
_conditionFilterText = conditionFilterText;
_targetFilterText = targetFilterText;
_skillTypeText = skillTypeText;
_ownerCardtype = ownerCardType;
_conditionFilter = new ConditionSkillFilterCollection();
_targetFilter = new ApplySkillTargetFilterCollection();
typeCheck = SetTypeCheck(_skillTypeText, _ownerCardtype);
_skillCreator = _ownerCard.CreateSkillCreator(_ownerCard.SelfBattlePlayer, _ownerCard.OpponentBattlePlayer, _ownerCard.ResourceMgr);
string[] array = _conditionFilterText.Split('&');
List<SkillFilterCreator.ContentInfo> list = new List<SkillFilterCreator.ContentInfo>();
for (int i = 0; i < array.Length; i++)
{
SkillFilterCreator.ParseContentInfo(array[i], out var retParsedInfo);
list.Add(retParsedInfo);
}
SkillCreator.SetupSkillConditionOld(_conditionFilter, list, _ownerCard, skill);
string[] array2 = _targetFilterText.Split('&');
List<SkillFilterCreator.ContentInfo> list2 = new List<SkillFilterCreator.ContentInfo>();
for (int j = 0; j < array2.Length; j++)
{
SkillFilterCreator.ParseContentInfo(array2[j], out var retParsedInfo2);
list2.Add(retParsedInfo2);
}
_skillCreator.SetupSkillTargetOld(_targetFilter, _ownerCard, list2, skill);
}
public List<BattleCardBase> GetAddTargetCard(SkillBase skill, BattlePlayerReadOnlyInfoPair pair, SkillConditionCheckerOption checkerOption, SkillOptionValue optionValue)
{
if (typeCheck(skill) && FilterComparison(skill.ApplyFilterCollection))
{
return _targetFilter.Filtering(pair, checkerOption, optionValue).Cast<BattleCardBase>().ToList();
}
return null;
}
private Func<SkillBase, bool> SetTypeCheck(string skillType, string ownerCardType)
{
if (skillType != null && skillType == "damage")
{
return (SkillBase skill) => CardTypeCheck(skill.SkillPrm.ownerCard, ownerCardType) && skill is Skill_damage;
}
return (SkillBase skill) => CardTypeCheck(skill.SkillPrm.ownerCard, ownerCardType) && skill is Skill_none;
}
private bool CardTypeCheck(BattleCardBase card, string ownerCardType)
{
return ownerCardType switch
{
"all" => true,
"unit" => card.IsUnit,
"spell" => card.IsSpell,
"field" => card.IsField,
"chant_field" => card.IsChantField,
_ => false,
};
}
private bool FilterComparison(ApplySkillTargetFilterCollection ownerSkillFilter)
{
if (_conditionFilter.BattlePlayerFilter.GetType() == ownerSkillFilter.BattlePlayerFilter.GetType() && _conditionFilter.TargetFilter.GetType() == ownerSkillFilter.TargetFilter.GetType())
{
foreach (ISkillCardFilter cardType in _conditionFilter.CardFilterList)
{
if (!ownerSkillFilter.CardFilterList.Any((ISkillCardFilter s) => s.GetType() == cardType.GetType()))
{
return false;
}
}
return true;
}
return false;
}
public AddTargetInfo Clone(BattleCardBase ownerCard)
{
return new AddTargetInfo(ownerCard, _conditionFilterText, _targetFilterText, _skillTypeText, _ownerCardtype, null);
}
}

View File

@@ -0,0 +1,77 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AlleyField : BackGroundBase
{
public override int FieldId => 22;
public override int FieldEffectId => 22;
public AlleyField(string bgmId = "NONE")
: base(bgmId)
{
}
protected override void BattleFieldBuild()
{
BattleCoroutine.GetInstance().StartCoroutine(BackGroundBase.ObjectChecker(0.5f, _str3DFieldPath, delegate
{
base.Field = GameObject.Find(_str3DFieldPath);
base.Field.transform.parent = GameMgr.GetIns().m_GameManagerObj.transform;
GimicAudioList = base.Field.GetComponent<AudioList>().GimicAudioList;
_fieldModel = base.Field.transform.Find("md_bf_aley_root").gameObject;
_fieldParticles = _fieldModel.transform.Find("Particles22").gameObject;
List<string> list = new List<string>(_fieldObjDictionary.Keys);
List<GameObject> list2 = new List<GameObject>();
for (int i = 0; i < _fieldObjDictionary.Count; i++)
{
list2.Add(_fieldObjDictionary[list[i]]);
}
GameMgr.GetIns().GetEffectMgr().SetUIParticleShader(list2, delegate
{
base.SetShaderGlobalColorBG = base.Field.transform.Find("SetMaterialColorBGManager").GetComponent<SetShaderGlobalColorBG>();
base.IsLoadDone = true;
}, isBattle: true, isField: true);
}));
}
public override void StartFieldSetEffect(Vector3 pos)
{
GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_FIELD_SET_22, pos);
}
public override void StartFieldTapEffect(int areaId, Vector3 pos)
{
base.StartFieldTapEffect(areaId, pos);
GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_FIELD_TAP_22_1, pos);
}
protected override IEnumerator RunFieldOpening()
{
GameMgr.GetIns().GetSoundMgr().PlaySeByStr($"se_field_{_str3DFieldNo}_appear_1", "se_field_" + _str3DFieldNo, 0f, 0L);
_battleCamera.Camera.transform.localPosition = new Vector3(2750f, -510f, -10f);
_battleCamera.Camera.transform.localRotation = Quaternion.Euler(new Vector3(-10f, -53f, 84f));
iTween.MoveTo(_battleCamera.Camera.gameObject, iTween.Hash("position", new Vector3(300f, -30f, -150f), "time", 2f, "islocal", true, "easetype", iTween.EaseType.easeInOutQuad));
iTween.RotateTo(_battleCamera.Camera.gameObject, iTween.Hash("rotation", new Vector3(-11f, -100f, 92f), "time", 2f, "islocal", true, "easetype", iTween.EaseType.easeInOutQuad));
yield return new WaitForSeconds(2f);
iTween.MoveTo(_battleCamera.Camera.gameObject, iTween.Hash("position", _battleCamera.BattleCameraPos, "time", 2f, "islocal", true, "easetype", iTween.EaseType.easeInOutExpo));
iTween.RotateTo(_battleCamera.Camera.gameObject, iTween.Hash("rotation", _battleCamera.BattleCameraRot, "time", 2f, "islocal", true, "easetype", iTween.EaseType.easeInOutExpo));
yield return new WaitForSeconds(0f);
}
protected override IEnumerator RunFieldGimic(GameObject obj)
{
string tag = obj.tag;
if (tag != null && tag == "FieldGimic1")
{
_ = _gimicCntDictionary[obj.tag];
}
yield return new WaitForSeconds(0f);
}
protected override IEnumerator RunFieldShake()
{
yield return new WaitForSeconds(0f);
}
}

View File

@@ -0,0 +1,8 @@
namespace AnimationOrTween;
public enum Direction
{
Reverse = -1,
Toggle,
Forward
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
using System.Collections.Generic;
using System.Linq;
using Wizard;
using Wizard.Battle;
public class ApplySkillTargetFilterCollection : SkillFilterCollectionBase
{
public List<ISkillCustomSelectFilter> ApplyCustomSelectFilterList { get; set; }
public List<ISkillExclutionFilter> ApplyExclutionFilterList { get; private set; }
public ISkillSelectFilter ApplySelectFilter { get; set; }
public List<ApplySkillTargetFilterCollection> ApplyAndFilter { get; set; }
public ApplySkillTargetFilterCollection()
{
ApplyCustomSelectFilterList = new List<ISkillCustomSelectFilter>();
ApplyExclutionFilterList = new List<ISkillExclutionFilter>();
ApplyAndFilter = new List<ApplySkillTargetFilterCollection>();
}
public List<IReadOnlyBattleCardInfo> Filtering(BattlePlayerReadOnlyInfoPair pair, SkillConditionCheckerOption checkerOption, SkillOptionValue optionValue)
{
List<IReadOnlyBattleCardInfo> list = new List<IReadOnlyBattleCardInfo>();
List<IReadOnlyBattleCardInfo> AndFilterTargets = new List<IReadOnlyBattleCardInfo>();
IEnumerable<IBattlePlayerReadOnlyInfo> battlePlayerInfos = null;
if (ApplyAndFilter.Count <= 0)
{
if (base.BattlePlayerFilter != null)
{
battlePlayerInfos = base.BattlePlayerFilter.Filtering(pair);
}
if (base.TargetFilter != null)
{
list = base.TargetFilter.Filtering(battlePlayerInfos, checkerOption).ToList();
if (BattleManagerBase.GetIns().XorShiftRandom(isSelf: true) != null && BattleManagerBase.GetIns().XorShiftRandom(isSelf: false) == null && !pair.ReadOnlySelf.IsPlayer && (base.TargetFilter is SkillTargetInHandCardFilter || base.TargetFilter is SkillTargetReturnCardFilter || base.TargetFilter is SkillTargetTokenDrawCardFilter))
{
return list;
}
}
foreach (ISkillCardFilter cardFilter in base.CardFilterList)
{
list = cardFilter.Filtering(list, optionValue).ToList();
}
int i = 0;
for (int count = ApplyCustomSelectFilterList.Count; i < count; i++)
{
list = ApplyCustomSelectFilterList[i].Filtering(list, battlePlayerInfos, checkerOption).ToList();
}
for (int j = 0; j < ApplyExclutionFilterList.Count; j++)
{
list = ApplyExclutionFilterList[j].Filtering(list, battlePlayerInfos, checkerOption, optionValue).ToList();
}
}
else
{
for (int k = 0; k < ApplyAndFilter.Count; k++)
{
List<BattleCardBase> cards = ApplyAndFilter[k].Filtering(pair, checkerOption, optionValue).Cast<BattleCardBase>().ToList();
List<IReadOnlyBattleCardInfo> collection = (from IReadOnlyBattleCardInfo x in ApplyAndFilter[k].SelectFilter.Filtering(cards, optionValue, checkerOption)
where !AndFilterTargets.Contains(x)
select x).ToList();
AndFilterTargets.AddRange(collection);
}
}
List<IReadOnlyBattleCardInfo> list2 = list.ToList();
list2.AddRange(AndFilterTargets);
return list2;
}
public bool SimpleFiltering(IReadOnlyBattleCardInfo targetCard, BattlePlayerReadOnlyInfoPair pair, SkillConditionCheckerOption checkerOption, SkillOptionValue optionValue)
{
List<IReadOnlyBattleCardInfo> list = new List<IReadOnlyBattleCardInfo> { targetCard };
IEnumerable<IBattlePlayerReadOnlyInfo> battlePlayerInfos = base.BattlePlayerFilter.Filtering(pair);
for (int i = 0; i < base.CardFilterList.Count; i++)
{
list = base.CardFilterList[i].Filtering(list, optionValue).ToList();
}
for (int j = 0; j < ApplyCustomSelectFilterList.Count; j++)
{
list = ApplyCustomSelectFilterList[j].Filtering(list, battlePlayerInfos, checkerOption).ToList();
}
for (int k = 0; k < ApplyExclutionFilterList.Count; k++)
{
list = ApplyExclutionFilterList[k].Filtering(list, battlePlayerInfos, checkerOption, optionValue).ToList();
}
return list.Count() > 0;
}
}

View File

@@ -0,0 +1,95 @@
using System.Collections.Generic;
using UnityEngine;
public class AreaBGInfo : MonoBehaviour
{
private readonly List<ChapterExtraData> _chapterExtraDatas = new List<ChapterExtraData>
{
new ChapterExtraData
{
SectionId = 2,
ExtraTextureChapter = 10,
BGExtraEffectPath = "scn_map_change_1",
SeType = Se.TYPE.SE_MAP_TREE_EFFECT
},
new ChapterExtraData
{
SectionId = 2,
ExtraTextureChapter = 11,
ExtraTextureIndex = { 1, 2 },
BGSuffix = "b"
},
new ChapterExtraData
{
SectionId = 2,
ExtraTextureChapter = 12,
ExtraTextureIndex = { 1, 2 },
BGSuffix = "b"
},
new ChapterExtraData
{
SectionId = 1,
ExtraTextureChapter = 4,
BGExtraEffectPath = "scn_map_change_1",
SeType = Se.TYPE.SE_MAP_TREE_EFFECT,
ClanType = CardBasePrm.ClanType.NEMESIS
},
new ChapterExtraData
{
SectionId = 1,
ExtraTextureChapter = 5,
ExtraTextureIndex = { 1, 2 },
BGSuffix = "b",
ClanType = CardBasePrm.ClanType.NEMESIS
},
new ChapterExtraData
{
SectionId = 1,
ExtraTextureChapter = 6,
ExtraTextureIndex = { 1, 2 },
BGSuffix = "b",
ClanType = CardBasePrm.ClanType.NEMESIS
},
new ChapterExtraData
{
SectionId = 9,
ExtraTextureChapter = 1,
BGSectionId = 4,
BGExtraEffectPath = "scn_map_change_4",
BGFirstClearEffectPath = "scn_map_change_2",
FirstClearSeType = Se.TYPE.SE_MAP_SECTION9_CHAPTER1,
SeType = Se.TYPE.SE_MAP_SECTION9_CHANGE_CHAPTER,
ChapterMoveTime = 0f,
FirstClearEffectDelayTime = 1.2f,
FirstClearMoveOutDelayTime = 0.5f
},
new ChapterExtraData
{
SectionId = 9,
ExtraTextureChapter = 2,
BGSectionId = 7,
BGExtraEffectPath = "scn_map_change_4",
BGFirstClearEffectPath = "scn_map_change_3",
FirstClearSeType = Se.TYPE.SE_MAP_SECTION9_CHAPTER2,
SeType = Se.TYPE.SE_MAP_SECTION9_CHANGE_CHAPTER,
FirstClearEffectDelayTime = 1.2f,
FirstClearMoveDelayTime = 1f
},
new ChapterExtraData
{
SectionId = 9003,
ExtraTextureIndex = { 1, 2 },
BGSuffix = "c"
}
};
public List<ChapterExtraData> GetExtraChapters(int sectionId, int? selectStoryClassId)
{
List<ChapterExtraData> list = _chapterExtraDatas.FindAll((ChapterExtraData item) => item.SectionId == sectionId);
if (sectionId == 20)
{
list.AddRange(AreaBGInfoSection20.GetChapterExtraDatas());
}
return list.FindAll((ChapterExtraData item) => (CardBasePrm.ClanType?)item.ClanType == (CardBasePrm.ClanType?)selectStoryClassId || item.ClanType == CardBasePrm.ClanType.NONE);
}
}

View File

@@ -0,0 +1,127 @@
using System.Collections.Generic;
using Cute;
public class AreaBGInfoSection20
{
public const int SECTION_ID = 20;
public const int WERUSA_START = 10;
private const int WERUSA_END = 17;
private const int WERUSA_BG_SECTION_ID = 12;
private const int LEVIRU_START = 18;
private const int LEVIRU_END = 25;
private const int LEVIRU_BG_SECTION_ID = 10;
private const int IZUNIA_CHANGE_TEXTURE_START = 3;
private const int IZUNIA_END = 9;
private const int IZUNIA_BG_SECTION_ID = 2;
public const int NATERA_START = 26;
private const int NATERA_END = 33;
private const int NATERA_BG_SECTION_ID = 9;
private const int LAST_BATTLE_START = 34;
private const int LAST_BATTLE_END = 40;
private const int LAST_BATTLE_BG_SECTION_ID = 20;
public static List<ChapterExtraData> GetChapterExtraDatas()
{
List<ChapterExtraData> list = new List<ChapterExtraData>();
list.AddRange(Chapter1_2());
list.AddRange(Chapter3_9());
list.AddRange(OtherWorldChapters(10, 17, 12, new List<int> { 1, 2, 6, 7 }, addTreeEffect: true));
list.AddRange(OtherWorldChapters(18, 25, 10, new List<int> { 1, 2 }, addTreeEffect: true));
list.AddRange(OtherWorldChapters(26, 33, 9, new List<int> { 2, 3, 4, 7, 8, 9 }, addTreeEffect: true));
list.AddRange(OtherWorldChapters(34, 40, 20, null, addTreeEffect: false));
return list;
}
private static List<ChapterExtraData> OtherWorldChapters(int start, int end, int section, List<int> extraTextureIndex, bool addTreeEffect)
{
List<ChapterExtraData> list = new List<ChapterExtraData>();
for (int i = start; i <= end; i++)
{
ChapterExtraData chapterExtraData = new ChapterExtraData
{
SectionId = 20,
ExtraTextureChapter = i,
BGSectionId = section
};
chapterExtraData.AddTreeEffect = addTreeEffect;
if (i == 17 || i == 25 || i == 33)
{
chapterExtraData.BGExtraEffectPath = "scn_map_change_9";
chapterExtraData.SeType = Se.TYPE.SE_MAP_SECTION9_CHANGE_CHAPTER;
}
if (extraTextureIndex.IsNotNullOrEmpty())
{
chapterExtraData.ExtraTextureIndex = extraTextureIndex;
chapterExtraData.BGSuffix = "b";
}
list.Add(chapterExtraData);
}
return list;
}
private static List<ChapterExtraData> Chapter1_2()
{
return new List<ChapterExtraData>
{
new ChapterExtraData
{
SectionId = 20,
ExtraTextureChapter = 1,
BGExtraEffectPath = "scn_map_change_9",
SeType = Se.TYPE.SE_MAP_SECTION20_CHANGE_CHAPTER1
},
new ChapterExtraData
{
SectionId = 20,
ExtraTextureChapter = 2,
BGExtraEffectPath = "scn_map_change_8",
AttachExtraEffectToBgRoot = true,
SeType = Se.TYPE.SE_MAP_TREE_EFFECT
}
};
}
private static List<ChapterExtraData> Chapter3_9()
{
List<ChapterExtraData> list = new List<ChapterExtraData>();
for (int i = 3; i <= 9; i++)
{
ChapterExtraData chapterExtraData = new ChapterExtraData
{
SectionId = 20,
ExtraTextureChapter = i,
ExtraTextureIndex = { 1, 2, 6 },
BGSectionId = 2,
BGSuffix = "b"
};
if (i == 9)
{
chapterExtraData.BGExtraEffectPath = "scn_map_change_9";
chapterExtraData.SeType = Se.TYPE.SE_MAP_SECTION9_CHANGE_CHAPTER;
}
list.Add(chapterExtraData);
}
return list;
}
public static bool IsSpeedUpParticleTransition(int previousChapter, int nextChapter)
{
bool flag = previousChapter == 33 && nextChapter == 32;
return previousChapter == 0 || flag;
}
}

View File

@@ -0,0 +1,530 @@
using System.Collections;
using System.Collections.Generic;
using Cute;
using UnityEngine;
using Wizard;
public class AreaSelInfo : MonoBehaviour
{
private enum eTableCategory
{
CARD,
SLEEVE,
OTHER,
MAX
}
private const float LEFTSTAGEINFO_X_IN = 0f;
private const float LEFTSTAGEINFO_X_OUT = -1120f;
private const int CLEARPRESENT_MAX = 3;
private static readonly Vector3 CLEARPRESENT_CARD_COLLISIONSIZE = new Vector3(175f, 230f, 1f);
private const int CLEARPRESENT_CARD_DEPTHOFFSET = 50;
private const int CLEARPRESENT_RESOURCELIST_CAPACITY = 2;
private static readonly string[] CLEARPRESENT_NAME = new string[10] { "", "Common_0205", "", "Common_0201", "", "", "", "", "", "Common_0115" };
private static readonly string[] CLEARPRESENT_THUMBNAIL_SPRITENAME = new string[10] { "", "thumbnail_liquid", "", "thumbnail_crystal", "", "", "thumbnail_card", "thumbnail_emblem", "thumbnail_title", "thumbnail_rupy" };
private readonly Vector3 REWARD_TABLE_DEFAULT_POSITION = new Vector3(45f, -85.3f, 0f);
private readonly Vector3 REWARD_TABLE_CARD_POSITION = new Vector3(28.5f, -85.3f, 0f);
private const float TABLE_CONTAINS_CARD_REWARD_OFFSET_X = -16f;
private const int REWARD_BG_BASIC_WIDTH = 250;
private const int REWARD_BG_OFFSET_WIDTH_PER_GOODS = 90;
private readonly Dictionary<UserGoods.Type, float> REWARD_BG_OFFSET_MAGNIFICATION = new Dictionary<UserGoods.Type, float>
{
{
UserGoods.Type.Degree,
2f
},
{
UserGoods.Type.Card,
1.2f
},
{
UserGoods.Type.Sleeve,
1.2f
},
{
UserGoods.Type.Skin,
1.2f
},
{
UserGoods.Type.RedEther,
1f
},
{
UserGoods.Type.Rupy,
1f
},
{
UserGoods.Type.Item,
1f
},
{
UserGoods.Type.Emblem,
1f
}
};
private const float LABEL_ONLY_DEGREE_OFFSET_Y = -10f;
[SerializeField]
private GameObject _clearRewardPrefab;
private List<AreaSelectClearReward> _clearRewardList = new List<AreaSelectClearReward>();
[SerializeField]
private UITable _tableRoot;
[SerializeField]
private UITable[] _tableRewardsCategory = new UITable[3];
[SerializeField]
private GameObject _cardObjEvacuationRoot;
[SerializeField]
private UISprite _spriteRewardBackground;
[SerializeField]
private UILabel _labelAcquired;
private List<UIBase_CardManager.CardObjData> _cardObjList = new List<UIBase_CardManager.CardObjData>();
[SerializeField]
private GameObject CardDetailRoot;
[SerializeField]
private CardDetailUI CardDetailPrefab;
private CardDetailUI _cardDetail;
private List<string> _loadFileList = new List<string>();
private bool _isLoadEnd = true;
public void SetClearPresent(StoryChapterData chapterData)
{
if (chapterData == null || chapterData.Rewards == null)
{
return;
}
if (chapterData.Rewards.Length != 0)
{
base.gameObject.SetActive(value: true);
bool isCleared = chapterData.IsCleared;
for (int i = 0; i < _clearRewardList.Count; i++)
{
_clearRewardList[i].gameObject.SetActive(value: false);
}
List<long> list = new List<long>();
for (int j = 0; j < chapterData.Rewards.Length; j++)
{
StoryChapterData.StoryReward storyReward = chapterData.Rewards[j];
if (storyReward == null)
{
continue;
}
if (j >= _clearRewardList.Count)
{
break;
}
if (storyReward.RewardType == 5)
{
if (list.Contains(storyReward.RewardUserGoodsId))
{
continue;
}
list.Add(storyReward.RewardUserGoodsId);
}
_clearRewardList[j].gameObject.SetActive(value: true);
_clearRewardList[j].ShowReward((UserGoods.Type)storyReward.RewardType, storyReward.RewardUserGoodsId, storyReward.RewardNumber, isCleared);
}
RepositionRewards();
SetRewardBackgroundWidth();
SetAcquiredLabel(isCleared);
}
else
{
base.gameObject.SetActive(value: false);
}
}
private void RepositionRewards()
{
for (int i = 0; i < _tableRewardsCategory.Length; i++)
{
_tableRewardsCategory[i].gameObject.SetActive(value: false);
}
for (int j = 0; j < _clearRewardList.Count; j++)
{
if (_clearRewardList[j].gameObject.activeSelf)
{
int num = _clearRewardList[j].RewardGoodsType switch
{
UserGoods.Type.Card => 0,
UserGoods.Type.Sleeve => 1,
_ => 2,
};
Transform obj = _clearRewardList[j].gameObject.transform;
obj.SetParent(_tableRewardsCategory[num].transform);
obj.SetAsLastSibling();
_tableRewardsCategory[num].gameObject.SetActive(value: true);
}
}
for (int k = 0; k < _tableRewardsCategory.Length; k++)
{
if (_tableRewardsCategory[k].gameObject.activeInHierarchy)
{
_tableRewardsCategory[k].Reposition();
}
}
_tableRoot.Reposition();
if (_tableRewardsCategory[0].gameObject.activeInHierarchy)
{
_tableRoot.transform.localPosition = REWARD_TABLE_CARD_POSITION;
for (int l = 0; l < _tableRewardsCategory.Length; l++)
{
if (l != 0)
{
Vector3 localPosition = _tableRewardsCategory[l].transform.localPosition;
localPosition.x += -16f;
_tableRewardsCategory[l].transform.localPosition = localPosition;
}
}
}
else
{
_tableRoot.transform.localPosition = REWARD_TABLE_DEFAULT_POSITION;
}
}
private void SetRewardBackgroundWidth()
{
float num = 0f;
for (int i = 0; i < _clearRewardList.Count; i++)
{
if (_clearRewardList[i].gameObject.activeInHierarchy)
{
UserGoods.Type rewardGoodsType = _clearRewardList[i].RewardGoodsType;
num += REWARD_BG_OFFSET_MAGNIFICATION[rewardGoodsType];
}
}
int width = 250 + (int)(90f * num);
_spriteRewardBackground.width = width;
}
private void SetAcquiredLabel(bool isAcquired)
{
_labelAcquired.gameObject.SetActive(isAcquired);
if (!isAcquired)
{
return;
}
float a = float.MaxValue;
float num = float.MinValue;
bool flag = true;
for (int i = 0; i < _clearRewardList.Count; i++)
{
if (_clearRewardList[i].gameObject.activeInHierarchy)
{
Transform rewardTransform = _clearRewardList[i].GetRewardTransform();
a = Mathf.Min(a, rewardTransform.position.x);
num = Mathf.Max(num, rewardTransform.position.x);
if (_clearRewardList[i].RewardGoodsType != UserGoods.Type.Degree)
{
flag = false;
}
}
}
Vector3 position = _labelAcquired.transform.position;
position.x = Mathf.Lerp(a, num, 0.5f);
_labelAcquired.transform.position = position;
Vector3 localPosition = _labelAcquired.transform.localPosition;
localPosition.y = _tableRoot.transform.localPosition.y + (flag ? (-10f) : 0f);
_labelAcquired.transform.localPosition = localPosition;
}
public void LoadClearPresent(IReadOnlyList<StoryChapterData> stageDataList)
{
if (!_isLoadEnd)
{
return;
}
_isLoadEnd = false;
ReleaseClearPresent();
if (CardDetailPrefab != null)
{
_cardDetail = Object.Instantiate(CardDetailPrefab);
_cardDetail.transform.parent = CardDetailRoot.transform;
_cardDetail.transform.localPosition = Vector3.zero;
_cardDetail.transform.localScale = Vector3.one;
_cardDetail.OnClose = OnCardDetailClose;
_cardDetail.gameObject.SetActive(value: false);
_cardDetail.Initialize(_cardDetail.gameObject.layer, CardMaster.CardMasterId.Default);
_cardDetail.IsShowFlavorTextButton = true;
_cardDetail.IsShowVoiceButton = true;
_cardDetail.IsShowEvolutionButton = true;
}
_clearRewardList.Clear();
for (int i = 0; i < 3; i++)
{
AreaSelectClearReward component = NGUITools.AddChild(_tableRoot.gameObject, _clearRewardPrefab).GetComponent<AreaSelectClearReward>();
_clearRewardList.Add(component);
}
List<int> list = new List<int>(2);
List<string> loadPath = new List<string>();
StoryChapterData.StoryReward storyReward = null;
for (int j = 0; j < stageDataList.Count; j++)
{
for (int k = 0; k < stageDataList[j].Rewards.Length; k++)
{
string text = string.Empty;
storyReward = stageDataList[j].Rewards[k];
if (storyReward == null)
{
continue;
}
if (storyReward.RewardType == 5)
{
if (!list.Contains((int)storyReward.RewardUserGoodsId))
{
list.Add((int)storyReward.RewardUserGoodsId);
}
}
else if (storyReward.RewardType == 4)
{
string userGoodsImageName = UserGoods.GetUserGoodsImageName(UserGoods.Type.Item, storyReward.RewardUserGoodsId);
text = Toolbox.ResourcesManager.GetAssetTypePath(userGoodsImageName, ResourcesManager.AssetLoadPathType.Item);
}
else if (storyReward.RewardType == 6)
{
long existingSleeveId = Toolbox.ResourcesManager.GetExistingSleeveId(storyReward.RewardUserGoodsId);
text = Toolbox.ResourcesManager.GetAssetTypePath(existingSleeveId.ToString(), ResourcesManager.AssetLoadPathType.SleeveTexture);
Sleeve sleeve = Data.Master.SleeveMgr.Get(existingSleeveId);
if (sleeve.IsPremiumSleeve)
{
UIManager.GetInstance().getUIBase_CardManager().AddPremireSleevePath(ref loadPath, sleeve);
}
}
else if (storyReward.RewardType == 8)
{
foreach (string degreeResource in DegreeHelper.GetDegreeResourceList(storyReward.RewardUserGoodsId, DegreeHelper.DegreeType.SMALL, isFetch: false))
{
if (!loadPath.Contains(degreeResource))
{
loadPath.Add(degreeResource);
}
}
}
else if (storyReward.RewardType == 7)
{
text = Toolbox.ResourcesManager.GetAssetTypePath(storyReward.RewardUserGoodsId.ToString(), ResourcesManager.AssetLoadPathType.Emblem_M);
}
else if (storyReward.RewardType == 10)
{
text = Toolbox.ResourcesManager.GetAssetTypePath(storyReward.RewardUserGoodsId.ToString(), ResourcesManager.AssetLoadPathType.ClassCharaSkinThumbnail);
}
if (text != string.Empty && !loadPath.Contains(text))
{
loadPath.Add(text);
}
}
}
if (list.Count == 0 && loadPath.Count == 0)
{
_isLoadEnd = true;
}
else
{
StartCoroutine(LoadClearPresentInner(list, loadPath));
}
}
private IEnumerator LoadClearPresentInner(List<int> cardidlist, List<string> rewardPathList)
{
UIManager uiMgr = UIManager.GetInstance();
UIBase_CardManager uiCardMgr = uiMgr.getUIBase_CardManager();
bool isLoadRewardEnd = false;
_loadFileList.Clear();
bool isLoadCard = cardidlist.Count > 0;
if (isLoadCard)
{
int layer = LayerMask.NameToLayer("FrontUI");
uiMgr.CardLoadSelect(base.gameObject, cardidlist, layer, is2D: true);
}
StartCoroutine(Toolbox.ResourcesManager.LoadAssetGroupAsync(rewardPathList, delegate
{
_loadFileList.AddRange(rewardPathList);
isLoadRewardEnd = true;
}));
while ((isLoadCard && (!uiCardMgr.getCreateEndFlag() || !uiCardMgr.isAssetAllReady)) || !isLoadRewardEnd)
{
yield return null;
}
_cardObjList = uiMgr.getCardList2DObjs();
if (_cardObjList != null)
{
for (int num = 0; num < _cardObjList.Count; num++)
{
GameObject cardObj = _cardObjList[num].CardObj;
if (!(cardObj == null))
{
cardObj.SetActive(value: false);
UITexture[] componentsInChildren = cardObj.GetComponentsInChildren<UITexture>(includeInactive: true);
for (int num2 = 0; num2 < componentsInChildren.Length; num2++)
{
componentsInChildren[num2].depth += 50;
}
UILabel[] componentsInChildren2 = cardObj.GetComponentsInChildren<UILabel>(includeInactive: true);
for (int num3 = 0; num3 < componentsInChildren2.Length; num3++)
{
componentsInChildren2[num3].depth += 50;
}
UISprite[] componentsInChildren3 = cardObj.GetComponentsInChildren<UISprite>(includeInactive: true);
for (int num4 = 0; num4 < componentsInChildren3.Length; num4++)
{
componentsInChildren3[num4].depth += 50;
}
cardObj.GetComponent<CardListTemplate>().HideNum();
cardObj.AddComponent<BoxCollider>().size = CLEARPRESENT_CARD_COLLISIONSIZE;
cardObj.AddComponent<UIEventListener>().onClick = _cardDetail.OnPushCardDetailOn;
}
}
}
for (int num5 = 0; num5 < 3; num5++)
{
_clearRewardList[num5].Init(_cardObjList, _cardObjEvacuationRoot);
}
_isLoadEnd = true;
}
public void ReleaseClearPresent()
{
if (_cardDetail != null)
{
Object.Destroy(_cardDetail.gameObject);
_cardDetail = null;
}
if (_cardObjList != null)
{
for (int i = 0; i < _cardObjList.Count; i++)
{
Object.Destroy(_cardObjList[i].CardObj.gameObject);
}
_cardObjList.Clear();
}
Toolbox.ResourcesManager.RemoveAssetGroup(_loadFileList);
_loadFileList.Clear();
}
private void OnCardDetailClose()
{
}
public bool GetLoadEnd()
{
return _isLoadEnd;
}
public void ResetInfoPosition()
{
base.gameObject.transform.localPosition = new Vector3(0f, base.gameObject.transform.localPosition.y, base.gameObject.transform.localPosition.z);
}
public void MoveToScreen(bool isIn, bool isImmediate)
{
MoveToScreenObj(base.gameObject, isIn ? 0f : (-1120f), isImmediate ? 0f : 0.5f);
}
public void MoveToScreenObj(GameObject target, float localPosX, float time)
{
if (Mathf.Approximately(time, 0f))
{
Vector3 localPosition = target.transform.localPosition;
localPosition.x = localPosX;
target.transform.localPosition = localPosition;
return;
}
iTween.MoveTo(target, iTween.Hash("islocal", true, "x", localPosX, "time", time));
}
public static string GetPresentItemName(int itemID, long userGoodsId)
{
switch ((UserGoods.Type)itemID)
{
case UserGoods.Type.RedEther:
case UserGoods.Type.Rupy:
return Data.SystemText.Get(CLEARPRESENT_NAME[itemID]);
case UserGoods.Type.Item:
{
Item item = Data.Master.ItemList.Find((Item data) => data.UserGoodsId == userGoodsId);
if (item == null)
{
return string.Empty;
}
return item.name;
}
case UserGoods.Type.Sleeve:
{
Sleeve sleeve = Data.Master.SleeveMgr.Get(userGoodsId);
if (sleeve == null)
{
return string.Empty;
}
return sleeve.sleeve_name;
}
case UserGoods.Type.Emblem:
{
Emblem emblem = Data.Master.EmblemMgr.Get(userGoodsId);
if (emblem == null)
{
return string.Empty;
}
return emblem._name;
}
case UserGoods.Type.Degree:
{
Degree degree = Data.Master.DegreeMgr.Get((int)userGoodsId);
if (degree == null)
{
return string.Empty;
}
return degree._name;
}
case UserGoods.Type.Skin:
{
ClassCharacterMasterData charaPrmByCharaId = GameMgr.GetIns().GetDataMgr().GetCharaPrmByCharaId((int)userGoodsId);
if (charaPrmByCharaId == null)
{
return string.Empty;
}
return charaPrmByCharaId.chara_name;
}
case UserGoods.Type.SpotCardPoint:
return Data.SystemText.Get("Common_0161");
case UserGoods.Type.MyPageBG:
return Data.Master.MyPageCustomBGMaster[userGoodsId.ToString()].Name;
default:
return string.Empty;
}
}
public static string GetPresentItemSpriteName(int itemID)
{
if (itemID < 0 || itemID >= CLEARPRESENT_THUMBNAIL_SPRITENAME.Length)
{
return string.Empty;
}
return CLEARPRESENT_THUMBNAIL_SPRITENAME[itemID];
}
}

View File

@@ -0,0 +1,489 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Cute;
using UnityEngine;
[Serializable]
public class AreaSelectBG
{
private const float BGTEXTURE_WIDTH = 1024f;
private const float BGTEXTURE_HEIGHT = 1024f;
private const int BGTEXTURE_NUM_X = 4;
private const int BGTEXTURE_NUM_Y = 3;
private const float BGTEXTURE_WIDTH_HALF_LEFT = 2048f;
private const float BGTEXTURE_WIDTH_HALF_RIGHT = 2560f;
private const float BGTEXTURE_HEIGHT_HALF_UP = 1536f;
private const float BGTEXTURE_HEIGHT_HALF_BOTTOM = 1536f;
private const float BGTEXTURE_DRAG_MARGIN = 5f;
private const float BGTEXTURE_DRAG_DECELERATION = 0.875f;
private const float BGTEXTURE_DRAGARROW_ANIM_SPEED = 1f;
private const float BGDRAG_SEC_MAXSPEED = 0.25f;
private const float BGDRAG_SEC_RESETUPTOTIME_MAX = 0.5f;
private AreaSelectUI _areaSelectUI;
[SerializeField]
private GameObject _BGRoot;
[SerializeField]
private UITexture[] _BGTexture;
[SerializeField]
private AreaBGInfo _areaBGInfo;
private ParticleSystem _bgEffect;
private AreaSelectEffectControlBase _bgEffectControl;
[SerializeField]
private GameObject _BGDragCollision;
private Vector2 _BGDragDelta = Vector2.zero;
private bool _isBGDragEnable;
private float _BGDragSec;
private float _BGDragSecResetUpToTime;
private List<string> _loadedResources = new List<string>();
private bool _isLoadEndBGTexture = true;
private bool _isLoadEndParticle = true;
private StorySectionData _sectionData;
private int? _sectionClassId;
private List<ChapterExtraData> _extraChapters = new List<ChapterExtraData>();
private bool _isNormalBgSet = true;
private bool _changeChapterFirstCall = true;
private float _currentAspectRatio;
private Vector3 _currentBGScale = Vector3.one;
private Vector3 _currentParentPos = Vector3.zero;
private Vector2 _minMovablePos = Vector3.one;
private Vector2 _maxMovablePos = Vector3.one;
public ChapterExtraData ChapterExtraData { get; private set; }
public ChapterExtraData TransitionChapterExtraData { get; private set; }
public int BeforeChapterId { get; private set; }
public GameObject GetBGRoot()
{
return _BGRoot;
}
public bool GetLoadEnd()
{
if (_isLoadEndBGTexture)
{
return _isLoadEndParticle;
}
return false;
}
public void SetActiveBGEffect(bool isActive)
{
_bgEffect.gameObject.SetActive(isActive);
if (_bgEffectControl != null)
{
_bgEffectControl.SetActiveBGEffect(isActive);
}
}
public bool IsBGDragEnable()
{
return _isBGDragEnable;
}
public void Init(AreaSelectUI areaSelectUI)
{
_areaSelectUI = areaSelectUI;
UIEventListener component = _BGDragCollision.GetComponent<UIEventListener>();
if (null != component)
{
component.onDrag = OnDragBG;
}
SetBGDragEnable(enable: false);
}
public void Term()
{
int i = 0;
for (int num = _BGTexture.Length; i < num; i++)
{
_BGTexture[i].mainTexture = null;
}
}
private string GetBackGroundPath(int backGroundId, int index, bool isFetch = false)
{
return Toolbox.ResourcesManager.GetAssetTypePath("bg_story_" + backGroundId.ToString("00") + "_" + (index + 1).ToString("00"), ResourcesManager.AssetLoadPathType.Background, isFetch);
}
private string GetExtraBackGroundPath(StorySectionData sectionData, int index, string suffix, bool isFetch = false)
{
return Toolbox.ResourcesManager.GetAssetTypePath("bg_story_" + sectionData.BackGroundId.ToString("00") + "_" + (index + 1).ToString("00") + suffix, ResourcesManager.AssetLoadPathType.Background, isFetch);
}
private string GetExtraBackGroundPath(int backgroundId, int index, string suffix, bool isFetch = false)
{
return Toolbox.ResourcesManager.GetAssetTypePath("bg_story_" + backgroundId.ToString("00") + "_" + (index + 1).ToString("00") + suffix, ResourcesManager.AssetLoadPathType.Background, isFetch);
}
private string GetMapEffectPath(int backGroundId, bool isFetch = false)
{
return Toolbox.ResourcesManager.GetAssetTypePath("scn_map_world_" + backGroundId, ResourcesManager.AssetLoadPathType.Effect2D, isFetch);
}
private string GetTreeEffectPath(int backGroundId, bool isFetch = false)
{
return Toolbox.ResourcesManager.GetAssetTypePath("scn_map_world_tree_" + backGroundId, ResourcesManager.AssetLoadPathType.Effect2D, isFetch);
}
public void LoadBG(StorySectionData sectionData, int? sectionClassId)
{
_sectionData = sectionData;
_sectionClassId = sectionClassId;
_extraChapters = _areaBGInfo.GetExtraChapters(_sectionData.Id, sectionClassId);
_loadedResources.Add(GetMapEffectPath(sectionData.BackGroundId));
_isLoadEndBGTexture = false;
for (int i = 0; i < _BGTexture.Length; i++)
{
_loadedResources.Add(GetBackGroundPath(sectionData.BackGroundId, i));
}
foreach (ChapterExtraData extraChapter in _extraChapters)
{
int num = sectionData.BackGroundId;
if (extraChapter.IsUseOtherSectionBG())
{
num = extraChapter.BGSectionId;
for (int j = 0; j < _BGTexture.Length; j++)
{
_loadedResources.Add(GetBackGroundPath(num, j));
}
_loadedResources.Add(GetMapEffectPath(num));
if (extraChapter.AddTreeEffect)
{
_loadedResources.Add(GetTreeEffectPath(num));
}
}
foreach (int item in extraChapter.ExtraTextureIndex)
{
_loadedResources.Add(GetExtraBackGroundPath(num, item, extraChapter.BGSuffix));
}
if (!string.IsNullOrEmpty(extraChapter.BGExtraEffectPath))
{
_loadedResources.Add(Toolbox.ResourcesManager.GetAssetTypePath(extraChapter.BGExtraEffectPath, ResourcesManager.AssetLoadPathType.Effect2D));
}
if (!string.IsNullOrEmpty(extraChapter.BGFirstClearEffectPath))
{
_loadedResources.Add(Toolbox.ResourcesManager.GetAssetTypePath(extraChapter.BGFirstClearEffectPath, ResourcesManager.AssetLoadPathType.Effect2D));
}
}
_areaSelectUI.StartCoroutine(Toolbox.ResourcesManager.LoadAssetGroupAsync(_loadedResources, _OnLoadEndBG));
_isLoadEndParticle = false;
}
public void UnLoadBG()
{
Toolbox.ResourcesManager.RemoveAssetGroup(_loadedResources);
_loadedResources.Clear();
_sectionData = null;
_sectionClassId = null;
}
private void _OnLoadEndBG()
{
List<GameObject> list = new List<GameObject>();
_bgEffect = UnityEngine.Object.Instantiate(Toolbox.ResourcesManager.LoadObject(GetMapEffectPath(_sectionData.BackGroundId, isFetch: true)) as GameObject).GetComponent<ParticleSystem>();
Vector3 localScale = _bgEffect.transform.localScale;
_bgEffect.transform.parent = _BGRoot.transform;
_bgEffect.transform.localScale = localScale;
_bgEffect.transform.localPosition = Vector3.zero;
_bgEffectControl = _bgEffect.gameObject.GetComponent<AreaSelectEffectControlBase>();
list.Add(_bgEffect.gameObject);
if (_bgEffectControl != null)
{
_bgEffectControl._backGroundEffects.Add(_bgEffectControl.BASE_EFFECT_INDEX, _bgEffect);
_bgEffectControl._backGroundEffects.Add(_sectionData.BackGroundId, _bgEffect);
}
for (int i = 0; i < _BGTexture.Length; i++)
{
_BGTexture[i].mainTexture = Toolbox.ResourcesManager.LoadObject(GetBackGroundPath(_sectionData.BackGroundId, i, isFetch: true)) as Texture;
}
foreach (ChapterExtraData extraChapter in _extraChapters)
{
Dictionary<int, Texture> bGTexture = extraChapter.BGTexture;
int num = _sectionData.BackGroundId;
if (extraChapter.IsUseOtherSectionBG())
{
num = extraChapter.BGSectionId;
for (int j = 0; j < _BGTexture.Length; j++)
{
bGTexture.Add(j, Toolbox.ResourcesManager.LoadObject(GetBackGroundPath(num, j, isFetch: true)) as Texture);
}
if (num != _sectionData.BackGroundId && !_bgEffectControl._backGroundEffects.ContainsKey(num))
{
ParticleSystem particleSystem = InitParticle(num, extraChapter.AddTreeEffect);
_bgEffectControl._backGroundEffects.Add(num, particleSystem);
list.Add(particleSystem.gameObject);
}
}
foreach (int item in extraChapter.ExtraTextureIndex)
{
if (!bGTexture.ContainsKey(item))
{
bGTexture.Add(item, Toolbox.ResourcesManager.LoadObject(GetExtraBackGroundPath(num, item, extraChapter.BGSuffix, isFetch: true)) as Texture);
}
}
if (!string.IsNullOrEmpty(extraChapter.BGExtraEffectPath))
{
string assetTypePath = Toolbox.ResourcesManager.GetAssetTypePath(extraChapter.BGExtraEffectPath, ResourcesManager.AssetLoadPathType.Effect2D, isfetch: true);
extraChapter.ExtraEffect = UnityEngine.Object.Instantiate(Toolbox.ResourcesManager.LoadObject(assetTypePath) as GameObject);
if (extraChapter.AttachExtraEffectToBgRoot)
{
extraChapter.ExtraEffect.transform.parent = _BGRoot.transform;
extraChapter.ExtraEffect.transform.localPosition = Vector3.zero;
}
else
{
extraChapter.ExtraEffect.transform.parent = _areaSelectUI.transform;
}
extraChapter.ExtraEffect.SetActive(value: false);
List<string> collection = GameMgr.GetIns().GetEffectMgr().SetUIParticleShader(extraChapter.ExtraEffect, null);
_loadedResources.AddRange(collection);
}
if (!string.IsNullOrEmpty(extraChapter.BGFirstClearEffectPath))
{
string assetTypePath2 = Toolbox.ResourcesManager.GetAssetTypePath(extraChapter.BGFirstClearEffectPath, ResourcesManager.AssetLoadPathType.Effect2D, isfetch: true);
extraChapter.FirstClearEffect = UnityEngine.Object.Instantiate(Toolbox.ResourcesManager.LoadObject(assetTypePath2) as GameObject);
extraChapter.FirstClearEffect.transform.parent = _areaSelectUI.transform;
extraChapter.FirstClearEffect.SetActive(value: false);
List<string> collection2 = GameMgr.GetIns().GetEffectMgr().SetUIParticleShader(extraChapter.FirstClearEffect, null);
_loadedResources.AddRange(collection2);
}
}
List<string> collection3 = GameMgr.GetIns().GetEffectMgr().SetUIParticleShader(list, delegate
{
_isLoadEndParticle = true;
});
_loadedResources.AddRange(collection3);
_isLoadEndBGTexture = true;
}
private ParticleSystem InitParticle(int bgId, bool addTreeEffect = false)
{
Vector3 vector = default(Vector3);
ParticleSystem component = UnityEngine.Object.Instantiate(Toolbox.ResourcesManager.LoadObject(GetMapEffectPath(bgId, isFetch: true)) as GameObject).GetComponent<ParticleSystem>();
vector = component.transform.localScale;
component.transform.parent = _BGRoot.transform;
component.transform.localScale = vector;
component.transform.localPosition = Vector3.zero;
if (addTreeEffect)
{
GameObject gameObject = UnityEngine.Object.Instantiate(Toolbox.ResourcesManager.LoadObject(GetTreeEffectPath(bgId, isFetch: true)) as GameObject);
gameObject.transform.parent = component.transform;
gameObject.transform.localScale = Vector3.one;
gameObject.transform.localPosition = Vector3.zero;
}
return component;
}
public void OnChangeSelectChapter(StoryChapterData chapterData, bool isFirstClear)
{
TransitionChapterExtraData = null;
if (_extraChapters.Count > 0)
{
int intChapterId = int.Parse(chapterData.ChapterId);
ChapterExtraData = _extraChapters.FirstOrDefault((ChapterExtraData n) => n.ExtraTextureChapter == intChapterId);
}
if (_bgEffectControl != null)
{
_bgEffectControl.OnChangeSelectChapter(this, _sectionData, _sectionClassId, chapterData, isFirstClear);
}
}
public void SetExtraTexture(int chapterId)
{
if (_extraChapters.Count == 0)
{
return;
}
ChapterExtraData chapterExtraData = _extraChapters.FirstOrDefault((ChapterExtraData n) => n.ExtraTextureChapter == chapterId || n.SectionId == 9003);
if (chapterExtraData != null && chapterExtraData.IsChangeBG())
{
int num = _sectionData.BackGroundId;
if (chapterExtraData.IsUseOtherSectionBG())
{
num = chapterExtraData.BGSectionId;
for (int num2 = 0; num2 < _BGTexture.Length; num2++)
{
_BGTexture[num2].mainTexture = Toolbox.ResourcesManager.LoadObject(GetBackGroundPath(num, num2, isFetch: true)) as Texture;
}
}
foreach (int item in chapterExtraData.ExtraTextureIndex)
{
_BGTexture[item].mainTexture = Toolbox.ResourcesManager.LoadObject(GetExtraBackGroundPath(num, item, chapterExtraData.BGSuffix, isFetch: true)) as Texture;
}
_isNormalBgSet = false;
}
else if (!_isNormalBgSet)
{
for (int num3 = 0; num3 < _BGTexture.Length; num3++)
{
_BGTexture[num3].mainTexture = Toolbox.ResourcesManager.LoadObject(GetBackGroundPath(_sectionData.BackGroundId, num3, isFetch: true)) as Texture;
}
_isNormalBgSet = true;
}
}
public List<int> GetChaptersWithDifferentBackgroundFrom(int chapterId)
{
ChapterExtraData chapterExtra = _extraChapters.FirstOrDefault((ChapterExtraData n) => n.ExtraTextureChapter == chapterId);
IEnumerable<ChapterExtraData> source = ((chapterExtra == null || !chapterExtra.IsUseOtherSectionBG()) ? _extraChapters.Where((ChapterExtraData c) => c.IsUseOtherSectionBG() && c.BGSectionId != _sectionData.BackGroundId) : _extraChapters.Where((ChapterExtraData c) => c.BGSectionId != chapterExtra.BGSectionId));
return source.Select((ChapterExtraData s) => s.ExtraTextureChapter).ToList();
}
public void SetExtraEffect(int chapterId, bool isFirstClear = false)
{
if (_extraChapters.Count == 0)
{
return;
}
List<ChapterExtraData> list = new List<ChapterExtraData>();
list = ((BeforeChapterId >= chapterId) ? _extraChapters.FindAll((ChapterExtraData n) => n.ExtraTextureChapter != 0 && BeforeChapterId > n.ExtraTextureChapter && n.ExtraTextureChapter >= chapterId) : _extraChapters.FindAll((ChapterExtraData n) => n.ExtraTextureChapter != 0 && BeforeChapterId <= n.ExtraTextureChapter && n.ExtraTextureChapter < chapterId));
BeforeChapterId = chapterId;
TransitionChapterExtraData = list.FirstOrDefault((ChapterExtraData n) => n.ExtraEffect != null);
if (isFirstClear)
{
return;
}
if (list.Count() == 0 || TransitionChapterExtraData == null)
{
_changeChapterFirstCall = false;
return;
}
TransitionChapterExtraData = list.FirstOrDefault((ChapterExtraData n) => n.ExtraEffect != null);
if (!_changeChapterFirstCall && TransitionChapterExtraData != null && TransitionChapterExtraData.ExtraEffect != null)
{
TransitionChapterExtraData.ExtraEffect.SetActive(value: false);
TransitionChapterExtraData.ExtraEffect.SetActive(value: true);
GameMgr.GetIns().GetSoundMgr().PlaySe(TransitionChapterExtraData.SeType);
}
_changeChapterFirstCall = false;
}
public IEnumerator SetClearEffect()
{
ChapterExtraData clearChapterExtraData = _extraChapters.FirstOrDefault((ChapterExtraData n) => n.ExtraTextureChapter == BeforeChapterId);
if (clearChapterExtraData != null && clearChapterExtraData.FirstClearEffect != null)
{
yield return new WaitForSeconds(clearChapterExtraData.FirstClearEffectDelayTime);
clearChapterExtraData.FirstClearEffect.SetActive(value: false);
clearChapterExtraData.FirstClearEffect.SetActive(value: true);
GameMgr.GetIns().GetSoundMgr().PlaySe(clearChapterExtraData.FirstClearSeType);
}
}
public void SetupEnd()
{
if (_bgEffectControl != null)
{
_bgEffectControl.SetupEnd();
}
}
public void OnDragBG(GameObject obj, Vector2 delta)
{
_BGDragSecResetUpToTime = 0.5f;
_BGDragSec += Time.deltaTime;
_BGDragSec = Mathf.Min(_BGDragSec, 0.25f);
float t = Mathf.Clamp(_BGDragSec / 0.25f, 0f, 1f);
_BGDragDelta = Vector2.Lerp(Vector2.zero, delta, t);
}
public void UpdateBGDrag()
{
if (_BGDragSecResetUpToTime > 0f)
{
_BGDragSecResetUpToTime -= Time.deltaTime;
if (_BGDragSecResetUpToTime < 0f)
{
_BGDragSecResetUpToTime = 0f;
_BGDragSec = 0f;
}
}
if (!Mathf.Approximately(_BGDragDelta.x, 0f) || !Mathf.Approximately(_BGDragDelta.y, 0f))
{
_BGDragDelta.x *= 0.875f;
_BGDragDelta.y *= 0.875f;
Vector3 localPosition = _BGRoot.transform.localPosition;
localPosition.x += _BGDragDelta.x;
localPosition.y += _BGDragDelta.y;
UpdateMovableRange();
localPosition.x = Mathf.Clamp(localPosition.x, _minMovablePos.x, _maxMovablePos.x);
localPosition.y = Mathf.Clamp(localPosition.y, _minMovablePos.y, _maxMovablePos.y);
_BGRoot.transform.localPosition = localPosition;
}
}
public void SetBGDragEnable(bool enable)
{
_isBGDragEnable = enable;
_BGDragCollision.SetActive(enable);
_BGDragDelta = Vector2.zero;
}
private void UpdateMovableRange()
{
float num = (float)Screen.width / (float)Screen.height;
Vector3 localScale = _BGRoot.transform.localScale;
Vector3 localPosition = _BGRoot.transform.parent.transform.localPosition;
if (!(localScale == _currentBGScale) || num != _currentAspectRatio || !(localPosition == _currentParentPos))
{
float num2 = UIManager.GetInstance().UIManagerRoot.manualHeight;
if (1.7777778f > num)
{
num2 *= 1.7777778f / num;
}
float num3 = num2 * num * 0.5f;
float num4 = num2 * 0.5f;
float num5 = 2048f * localScale.x;
float num6 = 2560f * localScale.x;
float num7 = 1536f * localScale.y;
float num8 = 1536f * localScale.y;
_minMovablePos.x = 0f - num6 + num3 + 5f - localPosition.x;
_maxMovablePos.x = num5 - num3 - 5f - localPosition.x;
_minMovablePos.y = 0f - num7 + num4 + 5f - localPosition.y;
_maxMovablePos.y = num8 - num4 - 5f - localPosition.y;
_currentAspectRatio = num;
_currentBGScale = localScale;
_currentParentPos = localPosition;
}
}
}

View File

@@ -0,0 +1,136 @@
using System.Collections.Generic;
using Cute;
using UnityEngine;
public class AreaSelectChapterEffect
{
private static readonly Vector3 EFFECT_SCALE = new Vector3(320f, 320f, 320f);
private AreaSelectUI _areaSelectUI;
private List<string> _loadedResources = new List<string>();
private bool _isLoadEnd = true;
private Transform _effectParent;
private Dictionary<string, ParticleSystem> _effectList = new Dictionary<string, ParticleSystem>();
private string _playingEffect = "";
public bool GetLoadEnd()
{
return _isLoadEnd;
}
public void Init(AreaSelectUI areaselectUI, Transform effectParent)
{
_areaSelectUI = areaselectUI;
_effectParent = effectParent;
_playingEffect = "";
}
public void Term()
{
foreach (KeyValuePair<string, ParticleSystem> effect in _effectList)
{
ParticleSystem value = effect.Value;
if (!(null == value))
{
Object.Destroy(value.gameObject);
}
}
_effectList.Clear();
_playingEffect = "";
}
public void LoadEffect(List<StoryChapterData> chapterDataList)
{
_isLoadEnd = false;
string path = "";
int i = 0;
for (int count = chapterDataList.Count; i < count; i++)
{
path = chapterDataList[i].ChapterEffectPath;
if (!string.IsNullOrEmpty(path) && !_effectList.ContainsKey(path))
{
_effectList.Add(path, null);
_loadedResources.Add(Toolbox.ResourcesManager.GetAssetTypePath(path, ResourcesManager.AssetLoadPathType.Effect2D));
}
}
_areaSelectUI.StartCoroutine(Toolbox.ResourcesManager.LoadAssetGroupAsync(_loadedResources, delegate
{
List<GameObject> list = new List<GameObject>(_effectList.Count);
int j = 0;
for (int count2 = chapterDataList.Count; j < count2; j++)
{
path = chapterDataList[j].ChapterEffectPath;
if (!string.IsNullOrEmpty(path) && !(null != _effectList[path]))
{
GameObject gameObject = Toolbox.ResourcesManager.LoadObject(Toolbox.ResourcesManager.GetAssetTypePath(path, ResourcesManager.AssetLoadPathType.Effect2D, isfetch: true)) as GameObject;
if (!(null == gameObject))
{
_effectList[path] = Object.Instantiate(gameObject).GetComponent<ParticleSystem>();
_effectList[path].transform.parent = _effectParent;
_effectList[path].transform.localPosition = Vector3.zero;
_effectList[path].transform.localScale = EFFECT_SCALE;
_effectList[path].gameObject.SetActive(value: false);
list.Add(_effectList[path].gameObject);
}
}
}
_loadedResources.AddRange(GameMgr.GetIns().GetEffectMgr().SetUIParticleShader(list, delegate
{
_isLoadEnd = true;
}));
}));
}
public void UnLoadEffect()
{
Toolbox.ResourcesManager.RemoveAssetGroup(_loadedResources);
_loadedResources.Clear();
}
public void PlayEffect(string path, Vector3 pos)
{
if (!string.IsNullOrEmpty(path) && !(_playingEffect == path) && _effectList.ContainsKey(path) && !(null == _effectList[path]))
{
_effectList[path].gameObject.SetActive(value: true);
_effectList[path].Play();
_effectList[path].transform.localPosition = pos;
_playingEffect = path;
SetParticleSystemsSpeed(1f);
}
}
public void StopEffect(float? simulationSpeedAfterStop)
{
if (_effectList.ContainsKey(_playingEffect) && !(null == _effectList[_playingEffect]))
{
if (simulationSpeedAfterStop.HasValue)
{
SetParticleSystemsSpeed(simulationSpeedAfterStop.Value);
}
_effectList[_playingEffect].Stop();
_playingEffect = "";
}
}
public string GetPlayingEffect()
{
return _playingEffect;
}
private void SetParticleSystemsSpeed(float speed)
{
ParticleSystem.MainModule main = _effectList[_playingEffect].main;
main.simulationSpeed = speed;
ParticleSystem[] componentsInChildren = _effectList[_playingEffect].GetComponentsInChildren<ParticleSystem>();
for (int i = 0; i < componentsInChildren.Length; i++)
{
ParticleSystem.MainModule main2 = componentsInChildren[i].main;
main2.simulationSpeed = speed;
}
}
}

View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using UnityEngine;
public class AreaSelectEffectControlBase : MonoBehaviour
{
[NonSerialized]
public Dictionary<int, ParticleSystem> _backGroundEffects = new Dictionary<int, ParticleSystem>();
[NonSerialized]
public readonly int BASE_EFFECT_INDEX = -1;
protected bool IsSetupEnd { get; private set; }
public virtual void SetupEnd()
{
IsSetupEnd = true;
}
public virtual void OnChangeSelectChapter(AreaSelectBG areaSelectBG, StorySectionData sectionData, int? sectionClassId, StoryChapterData chapterData, bool isFirstClear)
{
}
public virtual void SetActiveBGEffect(bool effect)
{
}
}

View File

@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class AreaSelectMapIcon
{
public const int MAPICONLIST_CAPACITY_DEFAULT = 8;
private readonly string MAP_ICON_EFFECT_NAME_CIRCLE4 = "ef_circle4_add_nor_1";
private readonly string MAP_ICON_EFFECT_NAME_TWINKLE1 = "ef_twinkle1_add_nor_1";
private readonly Color32 MAP_ICON_EFFECT_COLOR_CLEARED = new Color32(byte.MaxValue, 192, 64, byte.MaxValue);
private readonly Color32 MAP_ICON_EFFECT_COLOR_ALREADY_READ_CIRCLE4 = new Color32(78, 95, 125, byte.MaxValue);
private readonly Color32 MAP_ICON_EFFECT_COLOR_ALREADY_READ_TWINKLE1 = new Color32(190, 218, 242, byte.MaxValue);
[SerializeField]
private GameObject MapIconRoot;
[SerializeField]
private UISprite MapIconOriginal;
private List<UISprite> MapIconList;
private GameObject MapIconEffect;
public void Init()
{
}
public void Term()
{
MapIconEffect = null;
GameMgr.GetIns().GetEffectMgr().Stop(EffectMgr.EffectType.CMN_MAP_MAPICON_CLEARED);
GameMgr.GetIns().GetEffectMgr().Stop(EffectMgr.EffectType.CMN_MAP_MAPICON_NOTCLEARED);
}
public void SetupMapIcon(List<StoryChapterData> chapterDataList)
{
if (MapIconList != null)
{
int i = 0;
for (int count = MapIconList.Count; i < count; i++)
{
UnityEngine.Object.Destroy(MapIconList[i].gameObject);
}
}
MapIconList = new List<UISprite>(8);
UISprite uISprite = null;
for (int j = 0; j < chapterDataList.Count; j++)
{
if (chapterDataList[j].IsReleased)
{
uISprite = UnityEngine.Object.Instantiate(MapIconOriginal);
if (!(null == uISprite))
{
uISprite.transform.parent = MapIconRoot.transform;
uISprite.transform.localPosition = new Vector3(chapterDataList[j].MapIconPos.x, chapterDataList[j].MapIconPos.y);
uISprite.transform.localScale = Vector3.one;
uISprite.name = "mapicon_" + j;
uISprite.gameObject.SetActive(chapterDataList[j].IsDisplayMapIcon);
MapIconList.Add(uISprite);
}
}
}
MapIconOriginal.gameObject.SetActive(value: false);
}
public Vector3 GetMapIconPos(int chapterIndex, bool isLocal)
{
if (MapIconList == null)
{
return Vector3.zero;
}
if (chapterIndex < 0 || chapterIndex >= MapIconList.Count)
{
return Vector3.zero;
}
if (!isLocal)
{
return MapIconList[chapterIndex].transform.position;
}
return MapIconList[chapterIndex].transform.localPosition;
}
public void SetActiveMapIcon(int chapterIndex, bool isActive)
{
if (chapterIndex >= 0 && chapterIndex < MapIconList.Count)
{
MapIconList[chapterIndex].gameObject.SetActive(isActive);
}
}
public void StartMapIconEffect(StoryChapterData.ChapterClearStatus clearState, int chapterIndex)
{
Effect effect = null;
switch (clearState)
{
case StoryChapterData.ChapterClearStatus.Cleared:
effect = GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_MAP_MAPICON_CLEARED);
effect.ChangeParticleColor(MAP_ICON_EFFECT_COLOR_CLEARED);
break;
case StoryChapterData.ChapterClearStatus.AlreadyRead:
effect = GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_MAP_MAPICON_CLEARED);
MotionUtils.ChangeParticleSystemColor(effect.transform.Find(MAP_ICON_EFFECT_NAME_CIRCLE4).gameObject, MAP_ICON_EFFECT_COLOR_ALREADY_READ_CIRCLE4);
MotionUtils.ChangeParticleSystemColor(effect.transform.Find(MAP_ICON_EFFECT_NAME_TWINKLE1).gameObject, MAP_ICON_EFFECT_COLOR_ALREADY_READ_TWINKLE1);
break;
default:
effect = GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_MAP_MAPICON_NOTCLEARED);
break;
}
MapIconEffect = effect.GetGameObjIns();
UpdateMapIconEffectPos(chapterIndex);
}
public void StopMapIconEffect()
{
GameMgr.GetIns().GetEffectMgr().Stop(EffectMgr.EffectType.CMN_MAP_MAPICON_CLEARED);
GameMgr.GetIns().GetEffectMgr().Stop(EffectMgr.EffectType.CMN_MAP_MAPICON_NOTCLEARED);
}
public void UpdateMapIconEffectPos(int chapterIndex)
{
if (MapIconList != null && chapterIndex >= 0 && chapterIndex < MapIconList.Count && !(null == MapIconEffect))
{
Vector3 position = MapIconList[chapterIndex].transform.position;
MapIconEffect.transform.position = position;
}
}
public GameObject GetMapIconObject(int chapterIndex)
{
return MapIconList[chapterIndex].gameObject;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using UnityEngine;
public class AreaSelectUtility
{
public const string BTN_IMAGE_NAME_SUFFIX_OFF = "{0}_off";
public const string BTN_IMAGE_NAME_SUFFIX_ON = "{0}_on";
public static readonly string ChapterSelectBtnPathPrefix = "btn_story_select";
public static readonly Color32 CLEAR_LABEL_COLOR_CLEARD_GRADIENT_TOP = new Color32(byte.MaxValue, 245, 161, byte.MaxValue);
public static readonly Color32 CLEAR_LABEL_COLOR_CLEARD_GRADIENT_BUTTOM = new Color32(byte.MaxValue, 209, 71, byte.MaxValue);
public static readonly Color32 CLEAR_LABEL_COLOR_CLEARD_EFFECT_OUTLINE8 = new Color32(94, 67, 31, byte.MaxValue);
public static readonly Color32 CLEAR_LABEL_COLOR_ALREADY_READ_GRADIENT_TOP = new Color32(245, 249, byte.MaxValue, byte.MaxValue);
public static readonly Color32 CLEAR_LABEL_COLOR_ALREADY_READ_GRADIENT_BUTTOM = new Color32(190, 218, 242, byte.MaxValue);
public static readonly Color32 CLEAR_LABEL_COLOR_ALREADY_READ_EFFECT_OUTLINE8 = new Color32(60, 73, 96, byte.MaxValue);
public static void SetClearLabelColor(UILabel clearLabel, StoryChapterData.ChapterClearStatus clearState)
{
switch (clearState)
{
case StoryChapterData.ChapterClearStatus.Cleared:
clearLabel.gradientTop = CLEAR_LABEL_COLOR_CLEARD_GRADIENT_TOP;
clearLabel.gradientBottom = CLEAR_LABEL_COLOR_CLEARD_GRADIENT_BUTTOM;
clearLabel.effectColor = CLEAR_LABEL_COLOR_CLEARD_EFFECT_OUTLINE8;
break;
case StoryChapterData.ChapterClearStatus.AlreadyRead:
clearLabel.gradientTop = CLEAR_LABEL_COLOR_ALREADY_READ_GRADIENT_TOP;
clearLabel.gradientBottom = CLEAR_LABEL_COLOR_ALREADY_READ_GRADIENT_BUTTOM;
clearLabel.effectColor = CLEAR_LABEL_COLOR_ALREADY_READ_EFFECT_OUTLINE8;
break;
}
}
}

View File

@@ -0,0 +1,346 @@
using System.Collections.Generic;
using UnityEngine;
using Wizard;
using Wizard.Scripts.Network.Data.TableData.Arena.TwoPick;
using Wizard.Scripts.Network.Data.TaskData.Arena.TwoPick;
public class ArenaColosseum : ArenaEntryDataBase
{
public enum eRound
{
FinalNotAdvance = -1,
Round1 = 1,
Round2B = 2,
Round2A = 3,
FinalB = 4,
FinalA = 5,
FinalMin = 4,
RoundMax = 5,
Undecided = 6,
Lose = 7
}
public enum eStageNo
{
Stage1 = 1,
Stage2,
FinalStage,
Max
}
public enum eEntryStatus
{
TwoPickClassSelect = 1,
TwoPickCardSelect,
SetUpComplete
}
public enum eRule
{
NONE = 0,
RotationBo1 = 1,
UnlimitedBo1 = 2,
TwoPick = 3,
TwoPickChaos = 4,
Crossover = 5,
MyRotation = 6,
HOF = 31,
WindFall = 33,
Avatar = 39
}
public enum eDeckIndex
{
Main = 0,
First = 0,
Second = 1,
Third = 2
}
public struct Detail
{
public string RoundTimeText { get; set; }
public string RoundTimeStartText { get; set; }
public string RoundTimeEndText { get; set; }
public string GroupName { get; set; }
public int MaxBattleNum { get; set; }
public int BreakThroughNum { get; set; }
public int MaxEntryNum { get; set; }
}
public class TwoPick
{
public CandidateClass CandidateClass { get; set; }
public CandidateCardInfo CandidateCard { get; set; }
public Deck DeckData { get; set; }
public CandidateChaos CandidateChaos { get; set; }
}
public enum eResultEffect
{
None,
GroupA,
Final,
Clear
}
private bool _isRankMatching;
public bool CanUseNonPossessionCard;
public int DeckEntryId { get; set; }
public bool IsColosseumPeriod { get; set; }
public bool IsRoundPeriod { get; set; }
public eEntryStatus EntryStatus { get; set; }
public Format DeckFormat { get; set; }
public eRule Rule { get; set; }
public bool IsNormalTwoPick { get; set; }
public int ChaosNum { get; set; }
public bool IsTwoPickRule
{
get
{
if (Rule != eRule.TwoPick)
{
return Rule == eRule.TwoPickChaos;
}
return true;
}
}
public bool NeedsFirstTips { get; set; }
public int ColosseumId { get; set; }
public int ChaoseTipsId { get; set; }
public bool IsSpecialDeckSelectRule
{
get
{
if (Rule != eRule.HOF)
{
return Rule == eRule.WindFall;
}
return true;
}
}
public bool IsDeckMaxNumberChange => Rule == eRule.WindFall;
public int DeckMaxNumber
{
get
{
if (Rule == eRule.WindFall)
{
return 35;
}
return 40;
}
}
public bool IsRankMatching
{
get
{
return _isRankMatching;
}
set
{
if (_isRankMatching != value)
{
_isRankMatching = value;
if (RealTimeNetworkAgent.FinishTaskBase != null)
{
RealTimeNetworkAgent.FinishTaskBase = new ColosseumBattleFinishTask();
}
}
}
}
public List<DeckData> DeckList { get; set; }
public eRound Round { get; set; }
public int ServerRoundId { get; set; }
public eStageNo StageNo { get; set; }
public string Name { get; set; }
public List<ReceivedReward> RewardList { get; set; }
public eResultEffect ResultEffect { get; set; }
public string ColorCodeId { get; set; }
public string CardPool { get; set; }
public int RetryRemainingNum { get; set; }
public int BattleMax { get; set; }
public int ClearWinNum { get; set; }
public bool IsDeckEntry { get; set; }
public bool IsFreeEntry
{
get
{
return Data.MyPageNotifications.data.IsColosseumFreeEntry;
}
set
{
Data.MyPageNotifications.data.IsColosseumFreeEntry = value;
}
}
public bool IsRetry { get; set; }
public bool IsLastDay { get; set; }
public bool IsClear { get; set; }
public bool IsRetire { get; set; }
public bool IsFinish { get; set; }
public bool IsDeckDeleted { get; set; }
public bool IsFinalRoundTry { get; set; }
public eRound NextRound { get; set; }
public double RemainingUnixTime { get; set; }
public float RemainingSinceTime { get; set; }
public double RemainingServerUnixTime { get; set; }
public string NextRoundStartTimeText { get; set; }
public string NowRoundTimeText { get; set; }
public string ColosseumTimeText { get; set; }
public string AnnounceNo { get; set; }
public Detail[] DetailData { get; set; }
public eStageNo FocusStageNo { get; set; }
public int WinBattleNum { get; set; }
public List<bool> BattleResultList { get; set; }
public List<int> BoxGradeList { get; set; }
public int FinalRoundEliminateCount { get; set; }
public TwoPick TwoPickData { get; set; }
public ArenaColosseum()
{
base.LootBoxType = PlayerStaticData.LootBoxType.COLOSSEUM;
DeckList = new List<DeckData>();
RewardList = new List<ReceivedReward>();
BattleResultList = new List<bool>();
BoxGradeList = new List<int>();
DetailData = new Detail[5];
Rule = eRule.TwoPick;
TwoPickData = new TwoPick();
TwoPickData.CandidateClass = new CandidateClass();
TwoPickData.CandidateCard = new CandidateCardInfo();
TwoPickData.CandidateChaos = new CandidateChaos();
}
public int GetRoundNumber(eRound inRound)
{
switch (inRound)
{
case eRound.Round1:
return 1;
case eRound.Round2B:
case eRound.Round2A:
return 2;
default:
return 0;
}
}
public string GetGroupText(eRound inRound)
{
switch (inRound)
{
case eRound.Round2A:
case eRound.FinalA:
return Data.SystemText.Get("Colosseum_0020");
case eRound.Round2B:
case eRound.FinalB:
return Data.SystemText.Get("Colosseum_0021");
default:
return "";
}
}
public eStageNo GetStageNoFromRoundId(eRound inRoundId)
{
switch (inRoundId)
{
case eRound.Round1:
return eStageNo.Stage1;
case eRound.Round2B:
case eRound.Round2A:
return eStageNo.Stage2;
case eRound.FinalB:
case eRound.FinalA:
return eStageNo.FinalStage;
default:
return eStageNo.Stage1;
}
}
public bool IsFinalRound()
{
if (Round == eRound.FinalA || Round == eRound.FinalB)
{
return true;
}
return false;
}
public DialogBase CreateDetailDialog(GameObject defaultDetailPrefab)
{
DialogBase dialogBase = UIManager.GetInstance().CreateDialogClose();
GameObject gameObject = Object.Instantiate(defaultDetailPrefab);
dialogBase.SetObj(gameObject);
gameObject.GetComponent<ColosseumDetail>().Init(dialogBase);
return dialogBase;
}
public void ApiRuleParseAndSet(int apiRule)
{
ArenaColosseum colosseumData = Data.ArenaData.ColosseumData;
colosseumData.Rule = (eRule)apiRule;
colosseumData.DeckFormat = ArenaData.ApiDeckFormatParse(colosseumData.Rule);
}
}

View File

@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using LitJson;
using UnityEngine;
using Wizard;
using Wizard.Scripts.Network.Data.TaskData.Arena;
public class ArenaCompetition : ArenaEntryDataBase
{
public enum EntryStatusType
{
NotEntry,
NotChallenge,
NotRegistDeck,
InBattle
}
public enum FreebieStatusType
{
InFreeBattle,
CanPermanentEntry,
PermanentEntryDone
}
public enum EntryCostType
{
EntryWithFree,
EntryWithCost
}
private bool _isRankMatching;
public bool IsCompetitionPeriod { get; private set; }
public bool IsEntry { get; set; }
public bool IsInFreeBattleRegistDeck { get; set; }
public bool NeedsFirstTips { get; private set; }
public int CompetitionId { get; private set; }
public FreebieStatusType FreebieStatus { get; set; }
public Format DeckFormat { get; private set; }
public ArenaColosseum.eRule Rule { get; private set; }
public bool IsSpecialMode { get; private set; }
public string NowRoundTimeText { get; private set; }
public string EntryEndTimeText { get; private set; }
public string EndTimeText { get; private set; }
public double EntryRemainingUnixTime { get; set; }
public double RemainingUnixTime { get; set; }
public float RemainingSinceTime { get; set; }
public double RemainingServerUnixTime { get; set; }
public string EntryTimeText { get; private set; }
public List<DeckData> DeckList { get; set; }
public List<Wizard.Scripts.Network.Data.TaskData.Arena.Reward> EntryRewardList { get; set; }
public bool IsRewardReceived { get; private set; }
public string AnnounceId { get; private set; }
public string CompetitionName { get; private set; }
public int MaxEntryCount { get; private set; }
public int MaxChallengeCount { get; private set; }
public int MaxWinCount { get; private set; }
public int BestWinCount { get; private set; }
public int MaxLoseCount { get; private set; }
public int RestChallangeCount { get; private set; }
public int RestEntryCount { get; private set; }
public int CurrentWinCount { get; private set; }
public int FreebieChallengeCount { get; private set; }
public bool IsChampion { get; private set; }
public bool IsEntryTimeEnd { get; private set; }
public int MaxBattleCount { get; private set; }
public int IsCompletedTwoPickDeck { get; private set; }
public int MaxFreebieChallengeCount { get; private set; }
public EntryStatusType EntryStatus { get; private set; }
public EntryCostType CostType { get; private set; }
public bool IsRankMatching
{
get
{
return _isRankMatching;
}
set
{
if (_isRankMatching != value)
{
_isRankMatching = value;
if (RealTimeNetworkAgent.FinishTaskBase != null)
{
RealTimeNetworkAgent.FinishTaskBase = new CompetitionBattleFinishTask();
}
}
}
}
public ArenaCompetition()
{
}
public ArenaCompetition(JsonData responseData)
{
JsonData jsonData = responseData["data"]["competition_info"];
IsCompetitionPeriod = jsonData["is_competition_period"].ToBoolean();
if (IsCompetitionPeriod)
{
Rule = (ArenaColosseum.eRule)jsonData["deck_format"].ToInt();
DeckFormat = ArenaData.ApiDeckFormatParse(Rule);
IsEntry = jsonData["is_entry"].ToBoolean();
IsInFreeBattleRegistDeck = jsonData["is_in_battle"].ToBoolean();
IsSpecialMode = jsonData["is_special_mode"].ToInt() == 1;
string text = ConvertTime.ToLocal(DateTime.Parse(jsonData["entry_start_time"].ToString()));
EntryRemainingUnixTime = ConvertTime.DateTimeToUnixTime(DateTime.Parse(jsonData["entry_end_time"].ToString()));
string text2 = ConvertTime.ToLocal(DateTime.Parse(jsonData["entry_end_time"].ToString()));
EntryTimeText = Data.SystemText.Get("Colosseum_0033", text, text2);
EntryEndTimeText = text2;
string text3 = ConvertTime.ToLocal(DateTime.Parse(jsonData["start_time"].ToString()));
RemainingUnixTime = ConvertTime.DateTimeToUnixTime(DateTime.Parse(jsonData["end_time"].ToString()));
string text4 = ConvertTime.ToLocal(DateTime.Parse(jsonData["end_time"].ToString()));
NowRoundTimeText = Data.SystemText.Get("Colosseum_0033", text3, text4);
EndTimeText = text4;
RemainingSinceTime = Time.realtimeSinceStartup;
RemainingServerUnixTime = responseData["data_headers"]["servertime"].ToDouble();
NeedsFirstTips = jsonData.GetValueOrDefault("is_display_tips", 0) == 1;
CompetitionId = jsonData.GetValueOrDefault("competition_id", 0);
FreebieStatus = (FreebieStatusType)jsonData["freebie_status"].ToInt();
DeckList = new List<DeckData>();
EntryRewardList = new List<Wizard.Scripts.Network.Data.TaskData.Arena.Reward>();
JsonData jsonData2 = jsonData["featured_entry_reward_list"];
for (int i = 0; i < jsonData2.Count; i++)
{
Wizard.Scripts.Network.Data.TaskData.Arena.Reward item = new Wizard.Scripts.Network.Data.TaskData.Arena.Reward(jsonData2[i]);
EntryRewardList.Add(item);
}
IsRewardReceived = jsonData["is_received_featured_entry_reward"].ToBoolean();
if (jsonData["announce_id"] != null)
{
AnnounceId = jsonData["announce_id"].ToString();
}
MaxEntryCount = jsonData.GetValueOrDefault("max_entry_count", 0);
MaxChallengeCount = jsonData.GetValueOrDefault("max_challenge_count", 0);
MaxWinCount = jsonData.GetValueOrDefault("max_win_count", 0);
MaxLoseCount = jsonData.GetValueOrDefault("max_lose_count", 0);
MaxBattleCount = jsonData.GetValueOrDefault("max_battle_count", 0);
MaxFreebieChallengeCount = jsonData["max_freebie_challenge_count"].ToInt();
crystalCost = jsonData.GetValueOrDefault("crystal_cost", 0);
rupyCost = jsonData.GetValueOrDefault("rupy_cost", 0);
BestWinCount = jsonData["max_win_count_in_entry"].ToInt();
RestChallangeCount = jsonData["rest_challenge_num"].ToInt();
RestEntryCount = jsonData["rest_entry_num"].ToInt();
CurrentWinCount = jsonData["current_win_count"].ToInt();
FreebieChallengeCount = jsonData["freebie_challenge_count"].ToInt();
EntryStatus = (EntryStatusType)jsonData["entry_status"].ToInt();
CostType = (EntryCostType)jsonData["entry_type"].ToInt();
IsChampion = jsonData.GetValueOrDefault("is_champion", 0) == 1;
CompetitionName = jsonData.GetValueOrDefault("competition_name", string.Empty).Replace("\\n", "\n");
double num = RemainingServerUnixTime + (double)Time.realtimeSinceStartup - (double)RemainingSinceTime;
IsEntryTimeEnd = EntryRemainingUnixTime - num < 0.0;
bool flag = CompetitionId <= PlayerPrefsWrapper.GetValue(PlayerPrefsWrapper.COMPETITION_JOIN_BUTTON_LATEST_ID);
Data.MyPageNotifications.data.IsCompetitionBadge = !IsRewardReceived && EntryStatus == EntryStatusType.NotEntry && !IsEntryTimeEnd && !flag;
base.ExpirtyInfo = new ShopExpirtyInfo(jsonData["sales_period_info"]);
if (DeckFormat == Format.TwoPick)
{
IsCompletedTwoPickDeck = jsonData["is_completed_two_pick_deck"].ToInt();
}
}
base.LootBoxType = PlayerStaticData.LootBoxType.COMPETITION;
}
public void SetRestChallangeCountByEntry(JsonData responseData)
{
RestChallangeCount = responseData["rest_challenge_count"].ToInt();
IsEntry = true;
}
}

View File

@@ -0,0 +1,80 @@
using LitJson;
using Wizard;
public class ArenaData : HeaderData
{
public enum eARENA_PAY
{
None = 0,
Crystal = 1,
Ticket = 3,
Rupy = 4,
Free = 5
}
public ArenaTwoPickData TwoPickData { get; set; }
public SealedData SealedData { get; private set; }
public SealedMyPageResponseData SealedMyPageResponseData { get; private set; }
public ArenaColosseum ColosseumData { get; set; }
public ArenaCompetition CompetitionData { get; set; }
public ArenaData()
{
SealedData = new SealedData();
ColosseumData = new ArenaColosseum();
CompetitionData = new ArenaCompetition();
}
public ArenaData(JsonData data)
: this()
{
if (data != null)
{
JsonData data2 = data[0];
TwoPickData = new ArenaTwoPickData(data2);
}
}
public void ClearSealedData()
{
SealedData = new SealedData();
}
public void SetSealedMyPageResponseData(JsonData rootData)
{
if (rootData.Keys.Contains("sealed_info"))
{
SealedMyPageResponseData = new SealedMyPageResponseData(rootData["sealed_info"]);
}
}
public static Format ApiDeckFormatParse(ArenaColosseum.eRule rule)
{
Format format = Format.Rotation;
switch (rule)
{
case ArenaColosseum.eRule.RotationBo1:
return Format.Rotation;
case ArenaColosseum.eRule.UnlimitedBo1:
return Format.Unlimited;
case ArenaColosseum.eRule.TwoPick:
case ArenaColosseum.eRule.TwoPickChaos:
return Format.TwoPick;
case ArenaColosseum.eRule.HOF:
case ArenaColosseum.eRule.WindFall:
return Format.Max;
case ArenaColosseum.eRule.Crossover:
return Format.Crossover;
case ArenaColosseum.eRule.MyRotation:
return Format.MyRotation;
case ArenaColosseum.eRule.Avatar:
return Format.Avatar;
default:
return Format.Max;
}
}
}

View File

@@ -0,0 +1,158 @@
using System;
using System.Collections;
using UnityEngine;
using Wizard;
public abstract class ArenaEntryBase : MonoBehaviour
{
[SerializeField]
protected GameObject ArenaEntryDialog;
[SerializeField]
protected GameObject CompetitionEntryDialog;
[SerializeField]
protected UIButton ButtonEntry;
[SerializeField]
protected UIButton ButtonResume;
[SerializeField]
protected GameObject HeadLineObject;
private UIWidget[] _headlineWidgetArray;
private Color[] _headlineWidgetDefaultColorArray;
private Coroutine _initCoroutine;
protected bool _isFreeEntry;
protected bool _isCompetition;
protected string[] _labelsText;
protected string _entryDialogTitleText;
protected Action _initFunc;
protected Action _resumeFunc;
protected Func<bool> _isJoinFunc;
protected Action _freeEntryFunc;
protected Action _freeBattleFunc;
protected Func<bool> _isFreeBattleCompetition;
protected Action _entryFunc;
private const float GLAY_SCALE = 0.33f;
protected abstract void EntryDialogCreate(GameObject inDialog);
protected virtual void EntryBaseInit(GameObject costRootObject)
{
_headlineWidgetArray = costRootObject.transform.GetComponentsInChildren<UIWidget>();
_headlineWidgetDefaultColorArray = new Color[_headlineWidgetArray.Length];
for (int i = 0; i < _headlineWidgetArray.Length; i++)
{
_headlineWidgetDefaultColorArray[i] = _headlineWidgetArray[i].color;
}
}
private void OnEnable()
{
UpdateMenu();
}
private void OnDestroy()
{
if (_initCoroutine != null)
{
StopCoroutine(_initCoroutine);
}
}
public void UpdateMenu()
{
_initCoroutine = UIManager.GetInstance().StartCoroutine(_InitCoroutine());
}
protected virtual IEnumerator _InitCoroutine()
{
while (!MyPageMenu.IsMyPageRequestEnd)
{
yield return null;
}
if (_initFunc != null)
{
_initFunc();
}
ButtonEntry.onClick.Clear();
ButtonEntry.onClick.Add(new EventDelegate(delegate
{
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_BTN_DECIDE);
if (_entryFunc != null)
{
_entryFunc();
}
else if (_isFreeEntry)
{
_freeEntryFunc();
}
else
{
DialogBase.Size size = ((Data.ArenaData.CompetitionData.CostType != ArenaCompetition.EntryCostType.EntryWithCost) ? DialogBase.Size.M : DialogBase.Size.XL);
DialogBase dialogBase = UIManager.GetInstance().CreateDialogClose();
dialogBase.SetSize(size);
dialogBase.SetTitleLabel(_entryDialogTitleText);
GameObject gameObject = UnityEngine.Object.Instantiate(_isCompetition ? CompetitionEntryDialog : ArenaEntryDialog);
EntryDialogCreate(gameObject);
if (_isCompetition && Data.ArenaData.CompetitionData.CostType == ArenaCompetition.EntryCostType.EntryWithCost)
{
PlayerPrefsWrapper.SetValue(PlayerPrefsWrapper.COMPETITION_JOIN_BUTTON_LATEST_ID, Data.ArenaData.CompetitionData.CompetitionId);
Data.MyPageNotifications.data.IsCompetitionBadge = false;
UIManager.GetInstance()._Footer.UpdateArenaBadgeIcon();
UpdateCompetitionEntryBadge();
}
gameObject.GetComponent<ArenaEntryDialogBase>().ParentDialog = dialogBase;
dialogBase.SetObj(gameObject.gameObject);
DialogBase.ButtonLayout buttonLayout = DialogBase.ButtonLayout.CloseBtn;
dialogBase.SetButtonLayout(buttonLayout);
}
}));
ButtonResume.onClick.Clear();
ButtonResume.onClick.Add(new EventDelegate(delegate
{
_resumeFunc();
}));
UpdateEntryResumeButton();
}
protected virtual void UpdateCompetitionEntryBadge()
{
}
protected virtual void UpdateEntryResumeButton()
{
bool flag = _isJoinFunc();
ButtonEntry.gameObject.SetActive(!flag);
ButtonResume.gameObject.SetActive(flag);
if (flag)
{
for (int i = 0; i < _headlineWidgetArray.Length; i++)
{
_headlineWidgetArray[i].color = new Color(_headlineWidgetDefaultColorArray[i].r * 0.33f, _headlineWidgetDefaultColorArray[i].g * 0.33f, _headlineWidgetDefaultColorArray[i].b * 0.33f, 255f);
}
}
else
{
for (int j = 0; j < _headlineWidgetArray.Length; j++)
{
_headlineWidgetArray[j].color = _headlineWidgetDefaultColorArray[j];
}
}
}
}

View File

@@ -0,0 +1,16 @@
using Wizard;
public abstract class ArenaEntryDataBase
{
public bool isJoin;
public int crystalCost;
public int rupyCost;
public int ticketCost;
public ShopExpirtyInfo ExpirtyInfo { get; set; }
public PlayerStaticData.LootBoxType LootBoxType { get; set; }
}

View File

@@ -0,0 +1,175 @@
using UnityEngine;
using Wizard;
public abstract class ArenaEntryDialogBase : MonoBehaviour
{
protected Se.TYPE _entryButtonSe = Se.TYPE.SYS_BTN_DECIDE_TRANS;
private const int CHECK_DIALOG_DEPTH = 10;
private ArenaEntryDataBase _entryData;
private ArenaEntryDialogData _dialogData;
protected ArenaData.eARENA_PAY _payType;
public bool IsCompetition;
protected string _mainTextId;
protected string _ticketSpriteName;
protected string _arenaNameTextId;
public DialogBase ParentDialog { get; set; }
protected abstract void Init();
protected abstract int GetTicketNum();
protected abstract ArenaEntryDataBase GetEntryData();
private void Start()
{
Init();
_entryData = GetEntryData();
_dialogData = GetComponent<ArenaEntryDialogData>();
SystemText systemText = Data.SystemText;
if (_dialogData._mainText != null)
{
_dialogData._mainText.text = systemText.Get(_mainTextId);
}
if (_dialogData._ticketHaveTitle != null)
{
_dialogData._ticketHaveTitle.text = systemText.Get("Arena_0037");
_dialogData._ticketHaveNum.text = GetTicketNum().ToString();
_dialogData._ticketHaveUnit.text = systemText.Get("Common_0117");
_dialogData._ticketButtonTitle.text = systemText.Get("Arena_0004");
_dialogData._ticketButtonSubTitle.text = systemText.Get("Arena_0038");
_dialogData._ticketButtonUseNum.text = _entryData.ticketCost.ToString();
_dialogData._ticketSprite.spriteName = _ticketSpriteName;
_dialogData._ticketButton.onClick.Add(new EventDelegate(delegate
{
if (ParentDialog.GetNowScene() == DialogBase.DialogScene.WAIT)
{
OnClickTicketEntryButton();
}
}));
}
if (_dialogData._rupyHaveTitle != null)
{
_dialogData._rupyHaveTitle.text = systemText.Get("Shop_0065");
_dialogData._rupyHaveNum.text = PlayerStaticData.UserRupyCount.ToString();
_dialogData._rupyHaveUnit.text = systemText.Get("Common_0120");
_dialogData._rupyButtonTitle.text = (IsCompetition ? systemText.Get("Competition_0067") : systemText.Get("Arena_0036"));
_dialogData._rupyButtonSubTitle.text = systemText.Get("Shop_0062");
_dialogData._rupyButtonUseNum.text = _entryData.rupyCost.ToString();
_dialogData._rupyButton.onClick.Add(new EventDelegate(delegate
{
if (ParentDialog.GetNowScene() == DialogBase.DialogScene.WAIT)
{
OnClickRupyEntryButton();
}
}));
}
_dialogData._crystalHaveTitle.text = systemText.Get("Shop_0064");
_dialogData._crystalHaveNum.text = PlayerStaticData.UserCrystalCount.ToString();
_dialogData._crystalHaveUnit.text = systemText.Get("Common_0116");
_dialogData._crystalButtonTitle.text = (IsCompetition ? systemText.Get("Competition_0046") : systemText.Get("Arena_0023"));
_dialogData._crystalButtonSubTitle.text = systemText.Get("Shop_0061");
_dialogData._crystalButtonUseNum.text = _entryData.crystalCost.ToString();
if (_entryData.ticketCost > GetTicketNum())
{
SetButtonDisable(_dialogData._ticketButton, _dialogData._ticketButtonTitle);
}
if (_entryData.rupyCost > PlayerStaticData.UserRupyCount)
{
SetButtonDisable(_dialogData._rupyButton, _dialogData._rupyButtonTitle);
}
_dialogData._crystalButton.onClick.Add(new EventDelegate(delegate
{
if (ParentDialog.GetNowScene() == DialogBase.DialogScene.WAIT)
{
OnClickCrystalEntryButton();
}
}));
}
private void SetButtonDisable(UIButton in_Button, UILabel in_Label)
{
in_Button.GetComponent<UIButton>().isEnabled = false;
in_Label.color = LabelDefine.TEXT_COLOR_BUTTON_DISABLE;
in_Button.GetComponent<TweenColor>().duration = 0f;
}
private void OnClickTicketEntryButton()
{
int ticketNum = GetTicketNum();
SystemText systemText = Data.SystemText;
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_BTN_DECIDE);
DialogBase dialogBase = UIManager.GetInstance().CreateDialogClose();
dialogBase.SetTitleLabel(systemText.Get("Arena_0005"));
dialogBase.SetPanelDepth(10);
ArenaBuyDialog component = Object.Instantiate(_dialogData.BuyDialogObject).GetComponent<ArenaBuyDialog>();
dialogBase.SetObj(component.gameObject);
component.SetTicketConfirmDialog(_entryData.ticketCost, ticketNum, _arenaNameTextId, _ticketSpriteName);
dialogBase.onPushButton1 = Entry;
dialogBase.SetButtonLayout(DialogBase.ButtonLayout.BlueBtn_CancelBtn);
dialogBase.SetButtonText(systemText.Get("Dia_Arena_003_Button"));
dialogBase.ClickSe_Btn1 = _entryButtonSe;
_payType = ArenaData.eARENA_PAY.Ticket;
}
private void OnClickCrystalEntryButton()
{
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_BTN_DECIDE);
if (PlayerStaticData.IsLootBoxRegulation(_entryData.LootBoxType))
{
LootBoxDialogUtility.CreateLootBoxRegulationDialog(_entryData.LootBoxType);
return;
}
if (_entryData.crystalCost > PlayerStaticData.UserCrystalCount)
{
ShopCommonUtility.CreateCrystalShortagePopup();
return;
}
int userCrystalCount = PlayerStaticData.UserCrystalCount;
SystemText systemText = Data.SystemText;
DialogBase dialogBase = UIManager.GetInstance().CreateDialogClose();
dialogBase.SetTitleLabel(systemText.Get("Arena_0005"));
dialogBase.SetPanelDepth(10);
ArenaBuyDialog component = Object.Instantiate(_dialogData.BuyDialogObject).GetComponent<ArenaBuyDialog>();
dialogBase.SetObj(component.gameObject);
component.SetClystalConfirmDialog(_entryData.crystalCost, userCrystalCount, _arenaNameTextId, _entryData.ExpirtyInfo);
dialogBase.SetButtonLayout(DialogBase.ButtonLayout.BlueBtn_CancelBtn);
string text_btn = (IsCompetition ? systemText.Get("Competition_0036") : systemText.Get("Dia_Arena_004_Button"));
dialogBase.SetButtonText(text_btn);
dialogBase.ClickSe_Btn1 = _entryButtonSe;
dialogBase.onPushButton1 = Entry;
_payType = ArenaData.eARENA_PAY.Crystal;
}
private void OnClickRupyEntryButton()
{
int userRupyCount = PlayerStaticData.UserRupyCount;
SystemText systemText = Data.SystemText;
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_BTN_DECIDE);
DialogBase dialogBase = UIManager.GetInstance().CreateDialogClose();
dialogBase.SetTitleLabel(systemText.Get("Arena_0005"));
dialogBase.SetPanelDepth(10);
ArenaBuyDialog component = Object.Instantiate(_dialogData.BuyDialogObject).GetComponent<ArenaBuyDialog>();
dialogBase.SetObj(component.gameObject);
component.SetRupyConfirmDialog(_entryData.rupyCost, userRupyCount, _arenaNameTextId);
dialogBase.onPushButton1 = Entry;
dialogBase.SetButtonLayout(DialogBase.ButtonLayout.BlueBtn_CancelBtn);
string text_btn = (IsCompetition ? systemText.Get("Competition_0036") : systemText.Get("Dia_Arena_005_Button"));
dialogBase.SetButtonText(text_btn);
dialogBase.ClickSe_Btn1 = _entryButtonSe;
_payType = ArenaData.eARENA_PAY.Rupy;
}
protected virtual void Entry()
{
ParentDialog.CloseWithoutSelect();
}
}

View File

@@ -0,0 +1,52 @@
using UnityEngine;
public class ArenaEntryDialogData : MonoBehaviour
{
public GameObject BuyDialogObject;
public UILabel _mainText;
public UISprite _ticketSprite;
public UIButton _ticketButton;
public UIButton _rupyButton;
public UIButton _crystalButton;
public UILabel _ticketHaveTitle;
public UILabel _ticketHaveNum;
public UILabel _ticketHaveUnit;
public UILabel _ticketButtonTitle;
public UILabel _ticketButtonSubTitle;
public UILabel _ticketButtonUseNum;
public UILabel _rupyHaveTitle;
public UILabel _rupyHaveNum;
public UILabel _rupyHaveUnit;
public UILabel _rupyButtonTitle;
public UILabel _rupyButtonSubTitle;
public UILabel _rupyButtonUseNum;
public UILabel _crystalHaveTitle;
public UILabel _crystalHaveNum;
public UILabel _crystalHaveUnit;
public UILabel _crystalButtonTitle;
public UILabel _crystalButtonSubTitle;
public UILabel _crystalButtonUseNum;
}

View File

@@ -0,0 +1,80 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ArenaField : BackGroundBase
{
public override int FieldId => 9;
public ArenaField(string bgmId = "NONE")
: base(bgmId)
{
}
protected override void BattleFieldBuild()
{
BattleCoroutine.GetInstance().StartCoroutine(BackGroundBase.ObjectChecker(0.5f, _str3DFieldPath, delegate
{
base.Field = GameObject.Find(_str3DFieldPath);
base.Field.transform.parent = GameMgr.GetIns().m_GameManagerObj.transform;
GimicAudioList = base.Field.GetComponent<AudioList>().GimicAudioList;
_fieldModel = base.Field.transform.Find("md_bf_arna_root").gameObject;
_fieldParticles = _fieldModel.transform.Find("Particles09").gameObject;
_fieldObjDictionary.Add(_fieldParticles.name, _fieldParticles);
_fieldParticleSystemDictionary.Add("opening", _fieldParticles.transform.Find("opening").GetComponent<ParticleSystem>());
List<string> list = new List<string>(_fieldObjDictionary.Keys);
List<GameObject> list2 = new List<GameObject>();
for (int i = 0; i < _fieldObjDictionary.Count; i++)
{
list2.Add(_fieldObjDictionary[list[i]]);
}
GameMgr.GetIns().GetEffectMgr().SetUIParticleShader(list2, delegate
{
base.SetShaderGlobalColorBG = base.Field.transform.Find("SetMaterialColorBGManager").GetComponent<SetShaderGlobalColorBG>();
base.IsLoadDone = true;
}, isBattle: true, isField: true);
}));
}
public override void StartFieldSetEffect(Vector3 pos)
{
GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_FIELD_SET_9, pos);
}
public override void StartFieldTapEffect(int areaId, Vector3 pos)
{
base.StartFieldTapEffect(areaId, pos);
GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_FIELD_TAP_9_1, pos);
}
protected override IEnumerator RunFieldOpening()
{
GameMgr.GetIns().GetSoundMgr().PlaySeByStr($"se_field_{_str3DFieldNo}_appear_1", "se_field_" + _str3DFieldNo, 0f, 0L);
_fieldParticleSystemDictionary["opening"].Play();
_battleCamera.Camera.transform.localPosition = new Vector3(2700f, -880f, 300f);
_battleCamera.Camera.transform.localRotation = Quaternion.Euler(new Vector3(-19f, -90f, 90f));
Vector3[] bezierCubic = MotionUtils.GetBezierCubic(new Vector3(2700f, -880f, 300f), new Vector3(1700f, -550f, -220f), new Vector3(700f, -200f, 20f), new Vector3(-240f, -190f, -70f), 10);
iTween.MoveTo(_battleCamera.Camera.gameObject, iTween.Hash("path", bezierCubic, "movetopath", false, "time", 2f, "islocal", true, "easetype", iTween.EaseType.easeInOutQuad));
iTween.RotateTo(_battleCamera.Camera.gameObject, iTween.Hash("rotation", new Vector3(-37f, -117f, 107f), "time", 1f, "delay", 1f, "islocal", true, "easetype", iTween.EaseType.easeInOutQuad));
yield return new WaitForSeconds(2f);
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_CAMERA_ZOOM_OUT);
iTween.MoveTo(_battleCamera.Camera.gameObject, iTween.Hash("position", _battleCamera.BattleCameraPos, "time", 2f, "islocal", true, "easetype", iTween.EaseType.easeInOutExpo));
iTween.RotateTo(_battleCamera.Camera.gameObject, iTween.Hash("rotation", _battleCamera.BattleCameraRot, "time", 2f, "islocal", true, "easetype", iTween.EaseType.easeInOutExpo));
yield return new WaitForSeconds(0f);
}
protected override IEnumerator RunFieldGimic(GameObject obj)
{
string tag = obj.tag;
if (tag != null && tag == "FieldGimic1")
{
_ = _gimicCntDictionary[obj.tag];
}
yield return new WaitForSeconds(0f);
}
protected override IEnumerator RunFieldShake()
{
yield return new WaitForSeconds(0f);
}
}

View File

@@ -0,0 +1,101 @@
using Cute;
using UnityEngine;
using Wizard;
public class ArenaNextSceneSelector : INextSceneSelector
{
private BattleResultUIController m_battleResultNewControl;
private bool _movingToMyPage;
public ArenaNextSceneSelector(BattleResultUIController battleResultControl)
{
m_battleResultNewControl = battleResultControl;
_movingToMyPage = false;
}
public void Setup(bool isWin, GameObject gameObject)
{
if (m_battleResultNewControl.ResultMsgReportBtnFlag)
{
m_battleResultNewControl.ReportBtnObj.labels[0].text = Data.SystemText.Get("Con_Management_001_Button");
m_battleResultNewControl.ReportBtnObj.gameObject.SetActive(value: true);
m_battleResultNewControl.ReportBtnObj.buttons[0].onClick.Clear();
m_battleResultNewControl.ReportBtnObj.buttons[0].onClick.Add(new EventDelegate(delegate
{
ConsistencyReportButtonAction.CreateReportConfirmWindow();
}));
}
m_battleResultNewControl.MissionBtnObj.labels[0].text = Data.SystemText.Get("Battle_0200");
m_battleResultNewControl.MissionBtnObj.buttons[0].onClick.Clear();
m_battleResultNewControl.MissionBtnObj.buttons[0].onClick.Add(new EventDelegate(delegate
{
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_COMMON_BUTTON);
UIManager.GetInstance().createInSceneCenterLoading();
MissionInfoTask missionInfoTask = GameMgr.GetIns().GetMissionInfoTask();
missionInfoTask.SetParameter();
m_battleResultNewControl.StartCoroutine(Toolbox.NetworkManager.Connect(missionInfoTask, delegate
{
m_battleResultNewControl.CreateMissionList();
}, BaseTask.OnRequestFailed, BaseTask.OnFailedErrorCode));
}));
m_battleResultNewControl.HomeBtnObj.labels[0].text = Data.SystemText.Get("Battle_0202");
m_battleResultNewControl.HomeBtnObj.buttons[0].onClick.Clear();
m_battleResultNewControl.HomeBtnObj.buttons[0].onClick.Add(new EventDelegate(delegate
{
MoveToMyPage();
}));
if (GameMgr.GetIns().GetDataMgr().IsColosseumBattleType())
{
m_battleResultNewControl.RetryBtnObj.labels[0].text = Data.SystemText.Get("Battle_0489");
}
else if (GameMgr.GetIns().GetDataMgr().IsCompetitionBattleType())
{
m_battleResultNewControl.RetryBtnObj.labels[0].text = Data.SystemText.Get("Competition_0021");
}
else if (GameMgr.GetIns().GetDataMgr().m_BattleType == DataMgr.BattleType.Sealed)
{
m_battleResultNewControl.RetryBtnObj.labels[0].text = Data.SystemText.Get("Sealed_BattleResult_0001");
}
else
{
m_battleResultNewControl.RetryBtnObj.labels[0].text = Data.SystemText.Get("Battle_0203");
}
m_battleResultNewControl.RetryBtnObj.buttons[0].onClick.Clear();
m_battleResultNewControl.RetryBtnObj.buttons[0].onClick.Add(new EventDelegate(delegate
{
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_COMMON_BUTTON);
if (GameMgr.GetIns().GetDataMgr().m_BattleType == DataMgr.BattleType.TwoPick)
{
GameMgr.GetIns().GetBattleCtrl().BattleEnd(UIManager.ViewScene.TwoPick);
}
else if (GameMgr.GetIns().GetDataMgr().m_BattleType == DataMgr.BattleType.Sealed)
{
GameMgr.GetIns().GetBattleCtrl().BattleEnd(UIManager.ViewScene.Sealed);
}
else if (GameMgr.GetIns().GetDataMgr().IsCompetitionBattleType())
{
GameMgr.GetIns().GetBattleCtrl().BattleEnd(UIManager.ViewScene.CompetitionLobby);
}
else
{
GameMgr.GetIns().GetBattleCtrl().BattleEnd(UIManager.ViewScene.Colosseum);
}
}));
}
public void Show()
{
iTween.MoveTo(m_battleResultNewControl.ButtonGrid.gameObject, iTween.Hash("position", m_battleResultNewControl.DefaultPosDict["ButtonGrid"], "time", 0.5f, "islocal", true, "easetype", iTween.EaseType.easeOutExpo));
}
private void MoveToMyPage()
{
if (!_movingToMyPage)
{
_movingToMyPage = true;
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_BTN_CANCEL_TRANS);
GameMgr.GetIns().GetBattleCtrl().BattleEnd(UIManager.ViewScene.MyPage);
}
}
}

View File

@@ -0,0 +1,204 @@
using System.Collections;
using UnityEngine;
using Wizard;
public class ArenaResultAnimationAgent : ResultAnimationAgent
{
public override IEnumerator RunUI(BattleResultUIController battleResultControl, INextSceneSelector nextSceneSelector, bool isWin)
{
m_BattleCamera.m_CutInCamera.gameObject.SetActive(value: false);
if (battleResultControl.IsDraw)
{
battleResultControl.TitleWin.gameObject.SetActive(value: false);
battleResultControl.TitleLose.gameObject.SetActive(value: false);
battleResultControl.TitleDraw.gameObject.SetActive(value: true);
battleResultControl.TitleDraw.transform.localScale = Vector3.one * 10f;
battleResultControl.TitleDraw.alpha = 0f;
battleResultControl.Bg.color = new Color32(0, 48, 16, 0);
battleResultControl.ResultTitle.spriteName = "result_top_lose";
}
else if (isWin)
{
battleResultControl.TitleWin.gameObject.SetActive(value: true);
battleResultControl.TitleLose.gameObject.SetActive(value: false);
battleResultControl.TitleDraw.gameObject.SetActive(value: false);
battleResultControl.TitleWin.transform.localScale = Vector3.one * 10f;
battleResultControl.TitleWin.alpha = 0f;
battleResultControl.Bg.color = new Color32(32, 24, 0, 0);
battleResultControl.ResultTitle.spriteName = "result_top_win";
}
else
{
battleResultControl.TitleWin.gameObject.SetActive(value: false);
battleResultControl.TitleLose.gameObject.SetActive(value: true);
battleResultControl.TitleDraw.gameObject.SetActive(value: false);
battleResultControl.TitleLose.transform.localScale = Vector3.one * 10f;
battleResultControl.TitleLose.alpha = 0f;
battleResultControl.Bg.color = new Color32(0, 24, 48, 0);
battleResultControl.ResultTitle.spriteName = "result_top_lose";
}
battleResultControl.MainPanel.alpha = 1f;
yield return new WaitForSeconds(0.1f);
if (battleResultControl.IsDraw)
{
TweenAlpha.Begin(battleResultControl.TitleDraw.gameObject, 0.2f, 1f);
iTween.ScaleTo(battleResultControl.TitleDraw.gameObject, iTween.Hash("scale", Vector3.one, "time", 0.2f, "islocal", true, "easetype", iTween.EaseType.easeInQuad));
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_RESULT_YOULOSE);
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_JINGLE_LOSE);
}
else if (isWin)
{
TweenAlpha.Begin(battleResultControl.TitleWin.gameObject, 0.2f, 1f);
iTween.ScaleTo(battleResultControl.TitleWin.gameObject, iTween.Hash("scale", Vector3.one, "time", 0.2f, "islocal", true, "easetype", iTween.EaseType.easeInQuad));
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_RESULT_YOUWIN);
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_JINGLE_WIN);
}
else
{
TweenAlpha.Begin(battleResultControl.TitleLose.gameObject, 0.2f, 1f);
iTween.ScaleTo(battleResultControl.TitleLose.gameObject, iTween.Hash("scale", Vector3.one, "time", 0.2f, "islocal", true, "easetype", iTween.EaseType.easeInQuad));
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_RESULT_YOULOSE);
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_JINGLE_LOSE);
}
TweenAlpha.Begin(battleResultControl.Bg.gameObject, 0.5f, 0.75f);
yield return new WaitForSeconds(0.2f);
TweenAlpha.Begin(battleResultControl.ArcaneIn.gameObject, 0.5f, 1f);
TweenAlpha.Begin(battleResultControl.ArcaneOut.gameObject, 0.5f, 1f);
iTween.ScaleTo(battleResultControl.ArcaneIn.gameObject, iTween.Hash("scale", Vector3.one, "time", 2f, "islocal", true, "easetype", iTween.EaseType.easeOutExpo));
iTween.ScaleTo(battleResultControl.ArcaneOut.gameObject, iTween.Hash("scale", Vector3.one, "time", 2f, "islocal", true, "easetype", iTween.EaseType.easeOutExpo));
if (battleResultControl.IsDraw)
{
GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_RESULT_TITLE_3, Vector3.zero);
battleResultControl.TitleDraw.transform.localScale = Vector3.one;
iTween.ScaleTo(battleResultControl.TitleDraw.gameObject, iTween.Hash("scale", Vector3.one * 1.1f, "time", 2f, "islocal", true, "easetype", iTween.EaseType.linear));
}
else if (isWin)
{
GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_RESULT_TITLE_1, Vector3.zero);
battleResultControl.TitleWin.transform.localScale = Vector3.one;
iTween.ScaleTo(battleResultControl.TitleWin.gameObject, iTween.Hash("scale", Vector3.one * 1.1f, "time", 2f, "islocal", true, "easetype", iTween.EaseType.linear));
}
else
{
GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_RESULT_TITLE_2, Vector3.zero);
battleResultControl.TitleLose.transform.localScale = Vector3.one;
iTween.ScaleTo(battleResultControl.TitleLose.gameObject, iTween.Hash("scale", Vector3.one * 1.1f, "time", 2f, "islocal", true, "easetype", iTween.EaseType.linear));
}
HideEmotionMessage();
if (battleResultControl.ResultMsgWindowFlag)
{
StartCoroutine(battleResultControl.ShowSpecialResultInfo());
}
yield return new WaitForSeconds(2f);
RankWinnerReward winnerReward = GameMgr.GetIns()._rankWinnerReward;
if (winnerReward == null)
{
int value = PlayerPrefsWrapper.GetValue(PlayerPrefsWrapper.BATTLE_WINNER_REWARD_GRADE);
string value2 = PlayerPrefsWrapper.GetValue(PlayerPrefsWrapper.BATTLE_WINNER_REWARD_STRING);
if (value != 0 && value2 != "")
{
winnerReward = UIManager.GetInstance().createRankWinnerReward();
GameMgr.GetIns()._rankWinnerReward = winnerReward;
winnerReward.SetInfomation(value, value2);
winnerReward.gameObject.SetActive(value: false);
}
}
if (winnerReward != null && isWin)
{
float seconds = 3f;
StartCoroutine(winnerReward.ResultWinnerReward());
yield return new WaitForSeconds(seconds);
StartCoroutine(winnerReward.HideRewardObject());
}
if (!battleResultControl.IsDraw && ShowRewardDialog(battleResultControl))
{
while (battleResultControl.IsRewardWait)
{
yield return null;
}
}
if (Data.ArenaBattleFinish.data != null)
{
TreasureBoxCpResultInfo treasureBoxCpResultInfo = Data.ArenaBattleFinish.data.TreasureBoxCpResultInfo;
if (treasureBoxCpResultInfo.IsPlayGradeUpAnimation())
{
yield return TreasureBoxCpOpenBoxAnimation(battleResultControl, treasureBoxCpResultInfo.AfterGrade);
}
if (treasureBoxCpResultInfo.IsBoxOpened())
{
yield return CreateTreasureBoxCpRewardDialog(treasureBoxCpResultInfo);
}
}
if (battleResultControl.IsDraw)
{
TweenAlpha.Begin(battleResultControl.TitleDraw.gameObject, 0.2f, 0f);
GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_RESULT_BACK_3, battleResultControl.AnchorBottom.transform.position, battleResultControl.AnchorBottom.gameObject);
}
else if (isWin)
{
TweenAlpha.Begin(battleResultControl.TitleWin.gameObject, 0.2f, 0f);
iTween.ScaleTo(battleResultControl.TitleWin.gameObject, iTween.Hash("scale", Vector3.one * 3f, "time", 0.2f, "islocal", true, "easetype", iTween.EaseType.easeInQuad));
GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_RESULT_BACK_1, battleResultControl.AnchorBottom.transform.position, battleResultControl.AnchorBottom.gameObject);
}
else
{
TweenAlpha.Begin(battleResultControl.TitleLose.gameObject, 0.2f, 0f);
GameMgr.GetIns().GetEffectMgr().Start(EffectMgr.EffectType.CMN_RESULT_BACK_2, battleResultControl.AnchorBottom.transform.position, battleResultControl.AnchorBottom.gameObject);
}
yield return new WaitForSeconds(0.2f);
if (isWin)
{
GameMgr.GetIns().GetSoundMgr().PlayBGM(Bgm.BGM_TYPE.SYS_WIN_LOOP);
}
else
{
GameMgr.GetIns().GetSoundMgr().PlayBGM(Bgm.BGM_TYPE.SYS_LOSE_LOOP);
}
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_RESULT_WINDOW_APPER);
iTween.MoveTo(battleResultControl.ClassCharObj.gameObject, iTween.Hash("position", battleResultControl.DefaultPosDict["ClassCharObj"], "time", 0.5f, "delay", 0.1f, "islocal", true, "easetype", iTween.EaseType.easeOutExpo));
iTween.MoveTo(battleResultControl.ResultTitle.gameObject, iTween.Hash("position", battleResultControl.DefaultPosDict["ResultTitle"], "time", 0.5f, "delay", 0f, "islocal", true, "easetype", iTween.EaseType.easeOutExpo));
iTween.MoveTo(battleResultControl.ClassInfo.gameObject, iTween.Hash("position", battleResultControl.DefaultPosDict["ClassInfo"], "time", 0.5f, "delay", 0.3f, "islocal", true, "easetype", iTween.EaseType.easeOutExpo));
yield return new WaitForSeconds(1f);
if (isWin)
{
PlayWinVoice();
}
if (battleResultControl.AddClassExp > 0)
{
battleResultControl.SettingAddClassExpTextAnimation();
yield return new WaitForSeconds(0.5f);
for (int i = 0; i < 10; i++)
{
GameMgr.GetIns().GetSoundMgr().PlaySe(Se.TYPE.SYS_RESULT_GAUGEUP);
yield return new WaitForSeconds(0.05f);
}
yield return new WaitForSeconds(0.5f);
}
bool _isFinishBattlePass = false;
battleResultControl.SetBattlePassGauge(delegate
{
_isFinishBattlePass = true;
});
while (!_isFinishBattlePass)
{
yield return null;
}
if (Data.RedEtherCampaignResultData != null)
{
bool isFinishRedEther = false;
RedEtherCampaignPanel.Create(battleResultControl.gameObject, Data.RedEtherCampaignResultData, battleResultControl, delegate
{
isFinishRedEther = true;
});
while (!isFinishRedEther)
{
yield return null;
}
yield return ShowRewardDialog(Data.RedEtherCampaignResultData.RewardList);
}
battleResultControl.GreySpriteBGVisible = false;
nextSceneSelector.Show();
battleResultControl.PrepareAchievementLog();
battleResultControl.FinishResult();
}
}

View File

@@ -0,0 +1,22 @@
using UnityEngine;
public class ArenaResultAnimationHandler : IResultAnimationHandler
{
private readonly GameObject m_resultAnimationAgentObj;
private readonly ArenaResultAnimationAgent m_resultAnimationAgentIns;
public ResultAnimationAgent m_resultAnimationAgent => m_resultAnimationAgentIns;
public ArenaResultAnimationHandler(BattleCamera battleCamera)
{
m_resultAnimationAgentObj = new GameObject();
m_resultAnimationAgentIns = m_resultAnimationAgentObj.AddComponent<ArenaResultAnimationAgent>();
m_resultAnimationAgentIns.GetComponent<ArenaResultAnimationAgent>().SetBattleCamera(battleCamera);
}
public void Destroy()
{
Object.Destroy(m_resultAnimationAgentObj);
}
}

View File

@@ -0,0 +1,63 @@
using System.Collections.Generic;
using LitJson;
using Wizard;
using Wizard.Lottery;
public class ArenaResultReporter : IBattleResultReporter
{
public bool IsEnd => Data.ArenaBattleFinish.data != null;
public int ClassExp => GetClassExp();
public List<UserAchievement> UserAchievement => GetUserAchievementList();
public List<UserMission> UserMission => GetUserMissionList();
public List<ReceivedReward> MissionRewards => Data.ArenaBattleFinish.data._missionRewards;
public List<ReceivedReward> VictoryRewards => Data.ArenaBattleFinish.data._victoryRewards;
public LotteryApplyData LotteryData => LotteryApplyData.EmptyData();
public bool IsDataExist
{
get
{
if (Data.ArenaBattleFinish.data != null)
{
return Data.ArenaBattleFinish.data.IsProcessed;
}
return false;
}
}
public MyPageHomeDialogData HomeDialogData => null;
public void Report(bool isWin)
{
}
public void Destroy()
{
}
public JsonData GetFinishResponseData()
{
return Data.ArenaBattleFinish.data._responseData;
}
public List<UserAchievement> GetUserAchievementList()
{
return Data.ArenaBattleFinish.data.achieved_achievement_list;
}
public List<UserMission> GetUserMissionList()
{
return Data.ArenaBattleFinish.data.achieved_mission_list;
}
public int GetClassExp()
{
return Data.ArenaBattleFinish.data.get_class_chara_experience;
}
}

View File

@@ -0,0 +1,24 @@
using LitJson;
using Wizard;
public class ArenaTwoPickData : ArenaEntryDataBase
{
public ChallengeData ChallengeData { get; private set; }
public ArenaTwoPickData(JsonData data)
{
isJoin = data["is_join"].ToBoolean();
crystalCost = data["cost"].ToInt();
rupyCost = data["rupy_cost"].ToInt();
ticketCost = data["ticket_cost"].ToInt();
base.LootBoxType = PlayerStaticData.LootBoxType.TWOPICK;
if (data.Keys.Contains("sales_period_info"))
{
base.ExpirtyInfo = new ShopExpirtyInfo(data["sales_period_info"]);
}
if (data.Keys.Contains("format_info"))
{
ChallengeData = new ChallengeData(data["format_info"]);
}
}
}

View File

@@ -0,0 +1,117 @@
using System.Collections.Generic;
using UnityEngine;
public class ArrowControl : MonoBehaviour
{
[SerializeField]
private GameObject ArrowHead;
[SerializeField]
private GameObject ArrowEfc;
[SerializeField]
private int DivideCnt = 10;
[SerializeField]
private bool isEvo;
private IList<GameObject> ArrowEfcList;
private GameObject FromObj;
private GameObject ToObj;
private bool isOn;
private bool _isTargettingEnemy;
private float ChangeTime;
private IList<int> ArrowTarList;
private void Start()
{
ArrowEfcList = new List<GameObject>();
ArrowEfcList.Add(ArrowEfc);
for (int i = 1; i < DivideCnt; i++)
{
GameObject gameObject = Object.Instantiate(ArrowEfc);
if (!(null == gameObject))
{
gameObject.transform.parent = base.transform;
ArrowEfcList.Add(gameObject);
}
}
ArrowTarList = new List<int>();
for (int j = 0; j < DivideCnt; j++)
{
ArrowTarList.Add(j);
}
HideArrow();
}
private void Update()
{
if (isOn)
{
SetArrowLine();
}
}
public void ShowArrow(GameObject fromObj, GameObject toObj, bool isTargettingEnemy)
{
FromObj = fromObj;
ToObj = toObj;
_isTargettingEnemy = isTargettingEnemy;
isOn = true;
base.gameObject.SetActive(value: true);
}
public void HideArrow()
{
isOn = false;
for (int i = 0; i < DivideCnt; i++)
{
ArrowEfcList[i].SetActive(value: false);
}
base.gameObject.SetActive(value: false);
}
private void SetArrowLine()
{
if (isEvo)
{
ChangeTime -= Time.deltaTime * 5f;
}
else
{
ChangeTime -= Time.deltaTime;
}
if (ChangeTime <= 0f)
{
ChangeTime = 1f;
ArrowTarList.Add(ArrowTarList[0]);
ArrowTarList.RemoveAt(0);
}
ArrowHead.transform.position = ToObj.transform.position;
Vector3 position = FromObj.transform.position;
Vector3 position2 = ToObj.transform.position;
Vector3 p = (_isTargettingEnemy ? position : position2) + Vector3.back * Vector3.Distance(position, position2) + Vector3.down * Vector3.Distance(position, position2) * -0.5f;
Vector3[] array = new Vector3[DivideCnt];
array = MotionUtils.GetBezierQuad(position, p, position2, DivideCnt);
for (int i = 0; i < array.Length; i++)
{
float num = 1f - ChangeTime;
if (ArrowTarList[i] != 0)
{
ArrowEfcList[i].SetActive(value: true);
ArrowEfcList[i].transform.position = (array[ArrowTarList[i]] - array[ArrowTarList[i] - 1]) * num + array[ArrowTarList[i] - 1];
}
else
{
ArrowEfcList[i].SetActive(value: false);
ArrowEfcList[i].transform.position = array[0];
}
}
}
}

View File

@@ -0,0 +1,76 @@
using UnityEngine;
[ExecuteInEditMode]
public class AspectCamera : MonoBehaviour
{
public Vector2 aspect = new Vector2(4f, 3f);
public Color32 backgroundColor = Color.black;
private float aspectRate;
private Camera _camera;
private static Camera _backgroundCamera;
private int sizeVal = 1;
public const float LOWER_LIMIT_ASPECT_RATIO = 0.5625f;
public const float UPPER_LIMIT_ASPECT_RATIO = 0.4618f;
public const float LOWER_LIMIT_ASPECT_RATIO_RECIPROCAL = 1.7777778f;
private const float SAFE_AREA_RATE = 0.892f;
private const float SAFE_AREA_NONE_RATE = 1f;
public static float SafeAreaRate;
private void Start()
{
aspectRate = aspect.x / aspect.y;
_camera = GetComponent<Camera>();
SafeAreaRate = 1f;
float num = (float)Screen.height / (float)Screen.width;
if (num < 0.5625f)
{
num = Mathf.Max(num, 0.4618f);
float t = (0.5625f - num) / 0.10069999f;
SafeAreaRate = Mathf.Lerp(1f, 0.892f, t);
}
}
private void UpdateScreenRate()
{
float num = aspect.y / aspect.x;
float num2 = (float)Screen.height / (float)Screen.width;
if (num2 < 0.5625f)
{
num2 = 0.5625f;
}
if (num > num2)
{
float num3 = num2 / num;
_camera.rect = new Rect(0f, 0f, 1f, 1f);
_camera.orthographicSize = (float)sizeVal * num3;
}
else
{
float num4 = num / num2;
_camera.rect = new Rect(0f, 0f, 1f, 1f);
_camera.orthographicSize = (float)sizeVal / num4;
}
}
private bool IsChangeAspect()
{
return _camera.aspect == aspectRate;
}
private void Update()
{
UpdateScreenRate();
_camera.ResetAspect();
}
}

View File

@@ -0,0 +1,44 @@
using UnityEngine;
public class AspectCameraPerspective : MonoBehaviour
{
private Camera m_camera;
private bool m_isSetFOV;
public void UpdateFov()
{
m_isSetFOV = false;
}
private void Start()
{
m_camera = GetComponent<Camera>();
}
private void Update()
{
if (!m_isSetFOV && GameMgr.GetIns() != null && m_camera != null)
{
float num = 0f;
float num2 = 0f;
if (Screen.width > Screen.height)
{
num = Screen.width;
num2 = Screen.height;
}
else
{
num = Screen.height;
num2 = Screen.width;
}
float num3 = num / num2;
if (num3 > 1.7777778f)
{
num3 = 1.7777778f;
}
m_camera.fieldOfView = Mathf.Atan2(1f, num3) * 57.29578f * 2f;
m_isSetFOV = true;
}
}
}

View File

@@ -0,0 +1,54 @@
public class AssetBundleEditorTag
{
public enum BUNDLE_CATEGORY
{
BG,
CARD,
EFFECT,
MASTER,
STORY,
UI,
UIDOWNLOAD,
TUTORIAL,
PACKBOX,
UILANG,
STORYLANG,
FONT,
SLEEVE,
MAX
}
public enum CardStatType
{
CARD_STAT_NORMAL,
CARD_STAT_FOIL,
CARD_STAT_PROMOTION
}
public struct categoryProps
{
public string name;
public categoryProps(string in_name)
{
name = in_name;
}
}
public static categoryProps[] categoryNameList = new categoryProps[13]
{
new categoryProps("bg"),
new categoryProps("card"),
new categoryProps("effect"),
new categoryProps("master"),
new categoryProps("story"),
new categoryProps("ui"),
new categoryProps("uidownload"),
new categoryProps("tutorial"),
new categoryProps("packbox"),
new categoryProps("uilang"),
new categoryProps("storylang"),
new categoryProps("font"),
new categoryProps("sleeve")
};
}

View File

@@ -0,0 +1,74 @@
using System.Collections.Generic;
using Wizard.Battle;
public class AttachedSkillInformation
{
public SkillCollectionBase AttachedSkills { get; protected set; }
public List<string> OwnerCardNameList { get; protected set; }
public List<int> OwnerCardIdList { get; protected set; }
public List<long> DuplicateBanNum { get; protected set; }
public List<SkillBase> CreatorSkillList { get; protected set; }
public List<int> CreatorSkillIndexList { get; protected set; }
public AttachedSkillInformation(BattleCardBase card)
{
AttachedSkills = new SkillCollectionBase(card);
OwnerCardNameList = new List<string>();
OwnerCardIdList = new List<int>();
DuplicateBanNum = new List<long>();
CreatorSkillList = new List<SkillBase>();
CreatorSkillIndexList = new List<int>();
}
public AttachedSkillInformation(BattleCardBase card, SkillCollectionBase skills, List<string> nameList, List<int> idList, List<long> duplicateBanNum, List<SkillBase> createrList, List<int> creatorSkillIndexList)
{
AttachedSkills = skills.Clone(card);
OwnerCardNameList = new List<string>(nameList);
OwnerCardIdList = new List<int>(idList);
DuplicateBanNum = new List<long>(duplicateBanNum);
CreatorSkillList = new List<SkillBase>(createrList);
CreatorSkillIndexList = new List<int>(creatorSkillIndexList);
}
public void Add(SkillBase skill, string ownerCardName, int ownerCardID, long duplicateBanNum, SkillBase creatorSkill, int index)
{
AttachedSkills.Add(skill);
OwnerCardNameList.Add(ownerCardName);
OwnerCardIdList.Add(ownerCardID);
DuplicateBanNum.Add(duplicateBanNum);
CreatorSkillList.Add(creatorSkill);
CreatorSkillIndexList.Add(index);
}
public void Remove(SkillBase skill, BattleCardBase owner, long duplicateBanNum, SkillBase creatorSkill, int index)
{
string name = owner.GetName();
int cardId = owner.CardId;
Remove(skill, name, cardId, duplicateBanNum, creatorSkill, index);
}
public void Remove(SkillBase skill, string ownerCardName, int ownerCardID, long duplicateBanNum, SkillBase creatorSkill, int index)
{
AttachedSkills.Remove(skill);
OwnerCardNameList.Remove(ownerCardName);
OwnerCardIdList.Remove(ownerCardID);
DuplicateBanNum.Remove(duplicateBanNum);
CreatorSkillList.Remove(creatorSkill);
CreatorSkillIndexList.Remove(index);
}
public void Clear()
{
AttachedSkills.Clear();
OwnerCardNameList.Clear();
OwnerCardIdList.Clear();
DuplicateBanNum.Clear();
CreatorSkillList.Clear();
CreatorSkillIndexList.Clear();
}
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
using Wizard.Battle;
public class AttachingAbilityInfo
{
public SkillBase Skill { get; private set; }
public List<IReadOnlyBattleCardInfo> TargetCards { get; private set; }
public AttachingAbilityInfo(SkillBase skill, List<IReadOnlyBattleCardInfo> targetCards)
{
Skill = skill;
TargetCards = targetCards;
}
}

View File

@@ -0,0 +1,528 @@
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using Wizard;
using Wizard.Battle.Touch;
using Wizard.Battle.View;
using Wizard.Battle.View.Vfx;
public class AttackSelectControl
{
public class AttackPair
{
public class AttackPairCard
{
public IBattleCardView _battleCardView;
public bool _isReady;
public bool _hasStartedMoving;
public AttackPairCard(IBattleCardView battleCardBase)
{
_battleCardView = battleCardBase;
}
public AttackPairCard(AttackPairCard attackPairCard)
{
_battleCardView = attackPairCard._battleCardView;
_isReady = attackPairCard._isReady;
_hasStartedMoving = attackPairCard._hasStartedMoving;
}
public void Clear()
{
_battleCardView = null;
_isReady = false;
_hasStartedMoving = false;
}
}
public AttackPairCard _attackInitiator;
public AttackPairCard _attackTarget;
public bool IsAttackPairReady
{
get
{
if (_attackInitiator._isReady)
{
return _attackTarget._isReady;
}
return false;
}
}
public AttackPair(IBattleCardView attackInitiator, IBattleCardView attackTarget)
{
_attackInitiator = new AttackPairCard(attackInitiator);
_attackTarget = new AttackPairCard(attackTarget);
}
public AttackPair(AttackPair attackPair)
{
_attackInitiator = new AttackPairCard(attackPair._attackInitiator);
_attackTarget = new AttackPairCard(attackPair._attackTarget);
}
public bool Compare(IBattleCardView attackInitiatorView, IBattleCardView attackTargetView)
{
if (_attackInitiator._battleCardView == attackInitiatorView)
{
return _attackTarget._battleCardView == attackTargetView;
}
return false;
}
public void Clear()
{
_attackInitiator.Clear();
_attackTarget.Clear();
}
}
public class WaitUntilAttackPairIsReadyVfx : VfxBase
{
private AttackPair _attackPair;
public WaitUntilAttackPairIsReadyVfx(AttackPair attackPair)
{
_attackPair = attackPair;
}
public override void Play()
{
BattleCoroutine.GetInstance().StartCoroutine(Wait());
}
private IEnumerator Wait()
{
while (!_attackPair.IsAttackPairReady)
{
yield return null;
}
IsEnd = true;
}
}
private BattleCardBase currentAttackInitiatorBattleCard;
private bool areAttackPairsBeingUpdated;
private readonly AttackPair currentAttackPair = new AttackPair(null, null);
private readonly List<AttackPair> successfulAttackPairs = new List<AttackPair>();
public const float Z_FLOAT_AMOUNT = -100f;
private const float EPSILON = 0.1f;
private const float SMOOTHING_AMOUNT = 0.01f;
private const float DECAY_MULTIPLIER = 10f;
private const float IDLING_POSITION = 0.025390625f;
private IBattleCardView currentAttackInitiator
{
get
{
return currentAttackPair._attackInitiator._battleCardView;
}
set
{
currentAttackPair._attackInitiator._battleCardView = value;
}
}
private IBattleCardView currentAttackTarget
{
get
{
return currentAttackPair._attackTarget._battleCardView;
}
set
{
currentAttackPair._attackTarget._battleCardView = value;
}
}
public void Update()
{
float t = MotionUtils.CalculateFrameRateIndependantDampingConstant(0.01f, 10f);
if (currentAttackInitiator != null && !currentAttackInitiator._attackTargetSelectInfo.IsCardInvolvedInAttack)
{
MoveCardUpwards(currentAttackPair._attackInitiator, t);
}
if (currentAttackTarget != null && !currentAttackTarget._attackTargetSelectInfo.IsCardInvolvedInAttack)
{
MoveCardUpwards(currentAttackPair._attackTarget, t);
}
}
public void RegisterAttackInitiator(BattleCardBase attackInitiatorCard, BattlePlayerBase opponentBattlePlayer)
{
currentAttackInitiatorBattleCard = attackInitiatorCard;
currentAttackInitiator = attackInitiatorCard.BattleCardView;
ToggleAttackableCardFrameEffects(isEnabled: true, opponentBattlePlayer);
attackInitiatorCard.BattleCardView._attackTargetSelectInfo._isBeingSelectedInAttack = true;
if (!attackInitiatorCard.BattleCardView._attackTargetSelectInfo.IsCardInvolvedInAttack)
{
ResetCardOrientationAndStopMovement(attackInitiatorCard.BattleCardView);
}
}
public void RegisterAttackTarget(IBattleCardView attackTargetCard)
{
if (currentAttackTarget == attackTargetCard)
{
return;
}
if (attackTargetCard != null)
{
attackTargetCard._attackTargetSelectInfo._isBeingSelectedInAttack = true;
if (!attackTargetCard._attackTargetSelectInfo.IsCardInvolvedInAttack)
{
ResetCardOrientationAndStopMovement(attackTargetCard);
}
}
currentAttackPair._attackTarget._isReady = !IsCardTranslatable(attackTargetCard);
currentAttackPair._attackTarget._hasStartedMoving = !IsCardTranslatable(attackTargetCard);
ResetCardPosition(currentAttackTarget);
if (currentAttackTarget != null)
{
currentAttackTarget._attackTargetSelectInfo._isBeingSelectedInAttack = false;
}
currentAttackTarget = attackTargetCard;
}
public virtual void RegisterAttackPair(AttackPair attackPair)
{
IBattleCardView battleCardView = attackPair._attackInitiator._battleCardView;
IBattleCardView battleCardView2 = attackPair._attackTarget._battleCardView;
if (attackPair == null || battleCardView == null || battleCardView._attackTargetSelectInfo._attackPairsCardIsInvolvedIn == null)
{
ResetCardPosition(currentAttackInitiator);
ResetCardPosition(currentAttackTarget);
return;
}
successfulAttackPairs.Add(attackPair);
battleCardView._attackTargetSelectInfo._attackPairsCardIsInvolvedIn.Enqueue(attackPair);
battleCardView2._attackTargetSelectInfo._attackPairsCardIsInvolvedIn.Enqueue(attackPair);
if (!areAttackPairsBeingUpdated)
{
BattleCoroutine.GetInstance().StartCoroutine(UpdateAttackPairs());
}
}
public void CancelAttackSelect(bool wasAttackSuccessful, BattlePlayerBase opponentBattlePlayer)
{
if (wasAttackSuccessful)
{
AttackPair attackPair = new AttackPair(currentAttackPair);
RegisterAttackPair(attackPair);
}
else
{
ResetCardPosition(currentAttackInitiator);
ResetCardPosition(currentAttackTarget);
}
if (currentAttackInitiatorBattleCard != null)
{
ToggleAttackableCardFrameEffects(isEnabled: false, opponentBattlePlayer);
}
if (currentAttackInitiator != null)
{
currentAttackInitiator._attackTargetSelectInfo._isBeingSelectedInAttack = false;
}
if (currentAttackTarget != null)
{
currentAttackTarget._attackTargetSelectInfo._isBeingSelectedInAttack = false;
}
currentAttackInitiatorBattleCard = null;
currentAttackPair.Clear();
}
public void ResetCardOrientationAndStopMovement(IBattleCardView targetCard)
{
if (!targetCard._attackTargetSelectInfo.IsUneffectedByAttackTargetting)
{
iTween.Stop(targetCard.CardWrapObject);
targetCard.CardWrapObject.transform.rotation = Quaternion.identity;
}
}
public virtual VfxBase ResetCardAfterAttackOnReplay()
{
return InstantVfx.Create(delegate
{
for (int i = 0; i < successfulAttackPairs.Count(); i++)
{
IBattleCardView battleCardView = successfulAttackPairs[i]._attackInitiator._battleCardView;
if (battleCardView._attackTargetSelectInfo._attackPairsCardIsInvolvedIn.Count > 0)
{
battleCardView._attackTargetSelectInfo._attackPairsCardIsInvolvedIn.Dequeue();
}
ResetCardPosition(battleCardView);
IBattleCardView battleCardView2 = successfulAttackPairs[i]._attackTarget._battleCardView;
if (battleCardView2._attackTargetSelectInfo._attackPairsCardIsInvolvedIn.Count > 0)
{
battleCardView2._attackTargetSelectInfo._attackPairsCardIsInvolvedIn.Dequeue();
}
ResetCardPosition(battleCardView2);
}
});
}
public virtual VfxBase ResetCardAfterAttack(IBattleCardView cardToReset)
{
return InstantVfx.Create(delegate
{
if (cardToReset._attackTargetSelectInfo._attackPairsCardIsInvolvedIn.Count > 0)
{
cardToReset._attackTargetSelectInfo._attackPairsCardIsInvolvedIn.Dequeue();
}
if (cardToReset._attackTargetSelectInfo.IsCardInvolvedInAttack)
{
cardToReset._attackTargetSelectInfo.CurrentAttackPairCardIsInvolvedIn._attackTarget._isReady = true;
}
ResetCardPosition(cardToReset);
});
}
private void ResetCardPosition(IBattleCardView targetCard)
{
if (!BattleManagerBase.GetIns().IsRecovery && IsCardTranslatable(targetCard) && !targetCard._attackTargetSelectInfo.IsCardInvolvedInAttack && !targetCard._attackTargetSelectInfo.IsUneffectedByAttackTargetting)
{
ImmediateVfxMgr.GetInstance().Register(SequentialVfxPlayer.Create(InstantVfx.Create(delegate
{
iTween.Stop(targetCard.CardWrapObject);
}), new DelaySetupVfx(() => (targetCard._attackTargetSelectInfo._isBeingSelectedInAttack || targetCard._attackTargetSelectInfo.IsCardInvolvedInAttack) ? ((VfxBase)NullVfx.GetInstance()) : ((VfxBase)new FallToGroundVfx(targetCard.CardWrapObject)))));
}
}
public virtual void StartCardIdling(IBattleCardView battleCardView)
{
iTween.Stop(battleCardView.CardWrapObject);
iTween.MoveAdd(battleCardView.CardWrapObject, iTween.Hash("z", 0.025390625f, "time", Random.Range(0.5f, 0.6f), "looptype", iTween.LoopType.pingPong, "easetype", iTween.EaseType.easeInOutQuad));
}
public virtual VfxBase RemoveAttackPairVfx(IBattleCardView attackInitiator, IBattleCardView attackTarget)
{
AttackPair attackPairToRemove = null;
for (int i = 0; i < successfulAttackPairs.Count; i++)
{
if (successfulAttackPairs[i].Compare(attackInitiator, attackTarget))
{
attackPairToRemove = successfulAttackPairs[i];
break;
}
}
if (attackPairToRemove != null)
{
VfxBase vfxBase = CreateWaitUntilAttackPairIsReadyVfx(attackPairToRemove);
VfxBase vfxBase2 = InstantVfx.Create(delegate
{
successfulAttackPairs.Remove(attackPairToRemove);
});
return SequentialVfxPlayer.Create(vfxBase, vfxBase2);
}
return NullVfx.GetInstance();
}
private void ToggleAttackableCardFrameEffects(bool isEnabled, BattlePlayerBase opponentBattlePlayer)
{
List<BattleCardBase> classAndInPlayCardList = opponentBattlePlayer.ClassAndInPlayCardList;
for (int i = 0; i < classAndInPlayCardList.Count; i++)
{
if (CanCardAttackTarget(currentAttackInitiatorBattleCard, classAndInPlayCardList[i], opponentBattlePlayer.InPlayCards) && classAndInPlayCardList[i].AreCanBeAttackedConditionsFulfilled)
{
classAndInPlayCardList[i].BattleCardView._inPlayFrameEffect.ToggleTargetSelectEffect(isEnabled);
}
}
currentAttackInitiator._inPlayFrameEffect.ToggleTargetSelectEffect(isEnabled, isAttackTargetSelectInitiator: true);
}
private VfxBase CreateWaitUntilAttackPairIsReadyVfx(AttackPair attackPair)
{
return new WaitUntilAttackPairIsReadyVfx(attackPair);
}
private IEnumerator UpdateAttackPairs()
{
areAttackPairsBeingUpdated = true;
while (successfulAttackPairs.Count > 0)
{
float t = MotionUtils.CalculateFrameRateIndependantDampingConstant(0.01f, 10f);
for (int i = 0; i < successfulAttackPairs.Count; i++)
{
AttackPair attackPair = successfulAttackPairs[i];
if (!attackPair.IsAttackPairReady)
{
AttackPair.AttackPairCard attackInitiator = attackPair._attackInitiator;
AttackPair.AttackPairCard attackTarget = attackPair._attackTarget;
if (attackInitiator._battleCardView._attackTargetSelectInfo.CurrentAttackPairCardIsInvolvedIn == attackPair)
{
MoveCardUpwards(attackInitiator, t);
}
if (attackTarget._battleCardView._attackTargetSelectInfo.CurrentAttackPairCardIsInvolvedIn == attackPair)
{
MoveCardUpwards(attackTarget, t);
}
}
}
yield return null;
}
areAttackPairsBeingUpdated = false;
}
private void MoveCardUpwards(AttackPair.AttackPairCard attackPairCard, float t)
{
if (BattleManagerBase.GetIns().IsRecovery)
{
attackPairCard._isReady = true;
}
else
{
if (attackPairCard == null || attackPairCard._battleCardView == null)
{
return;
}
IBattleCardView battleCardView = attackPairCard._battleCardView;
if (IsCardTranslatable(battleCardView) && !battleCardView._attackTargetSelectInfo.IsUneffectedByAttackTargetting && !attackPairCard._isReady)
{
if (!attackPairCard._hasStartedMoving)
{
attackPairCard._hasStartedMoving = true;
ResetCardOrientationAndStopMovement(battleCardView);
}
Transform transform = battleCardView.CardWrapObject.transform;
if (!IsCardFullyTranslated(battleCardView))
{
Vector3 b = CalculateFinalFloatingPosition(battleCardView);
transform.localPosition = Vector3.Lerp(transform.transform.localPosition, b, t);
}
else
{
transform.localPosition = CalculateFinalFloatingPosition(battleCardView);
attackPairCard._isReady = true;
StartCardIdling(battleCardView);
}
}
}
}
private Vector3 CalculateFinalFloatingPosition(IBattleCardView battleCardView)
{
Vector3 localPosition = battleCardView.CardWrapObject.transform.transform.localPosition;
localPosition.z = -100f;
return localPosition;
}
public bool IsCardTranslatable(IBattleCardView cardToTranslate)
{
if (cardToTranslate != null)
{
return !cardToTranslate.CardInfo.IsClass;
}
return false;
}
private bool IsCardFullyTranslated(IBattleCardView cardBeingTranslated)
{
return Mathf.Abs(cardBeingTranslated.CardWrapObject.transform.localPosition.z - -100f) < 0.1f;
}
public static bool CanCardAttackTarget(BattleCardBase Attacker, BattleCardBase Target, IEnumerable<BattleCardBase> TargetInPlayCards)
{
bool flag = false;
bool isClass = Target.IsClass;
if (TargetInPlayCards.Any((BattleCardBase c) => c.SkillApplyInformation.IsGuard && !c.CantBeFocusedAttack(Attacker)))
{
flag = true;
}
if (Attacker.SkillApplyInformation.IsIgnoreGuard)
{
flag = false;
}
if (Attacker.AttackableCount <= 0)
{
return false;
}
if ((!Attacker.SkillApplyInformation.IsQuick || !Attacker.SkillApplyInformation.IsRush) && !Attacker.Attackable)
{
return false;
}
if (isClass)
{
if (!Attacker.SkillApplyInformation.IsQuick)
{
if (Attacker.IsFirstTurn)
{
return false;
}
if (!Attacker.Attackable)
{
return false;
}
}
if (Attacker.IsCantAttackClass)
{
return false;
}
if (Attacker.SkillApplyInformation.IsForceAttackUnit && Attacker.OpponentBattlePlayer.InPlayCards.Any((BattleCardBase c) => !c.CantBeFocusedAttack(Attacker) && c.IsUnit && !AttackTargetSelectTouchProcessor.CheckAttackToUnitNotHasGuardError(Attacker, c)))
{
return false;
}
}
if (!Target.IsInplay)
{
return false;
}
if (Target.IsField || Target.CantBeFocusedAttack(Attacker))
{
return false;
}
if (flag && (isClass || !Target.SkillApplyInformation.IsGuard))
{
return false;
}
if (isClass && Attacker.IsCantAttackClass)
{
return false;
}
if (Target.IsUnit && Attacker.SkillApplyInformation.IsSkillCantAtkUnit)
{
return false;
}
if (Target.IsUnit && Attacker.SkillApplyInformation.IsSkillCantAtkUnitBaseCardId && Attacker.SkillApplyInformation.CantAtkUnitBaseCardIdList.Contains(Target.BaseParameter.BaseCardId))
{
return false;
}
if (!isClass && Attacker.SkillApplyInformation.IsSkillCantAtkUnitNotHasGuard && !Target.SkillApplyInformation.IsGuard)
{
return false;
}
return true;
}
public static bool IsAttackPossible(BattleCardBase attacker, BattleCardBase target, IEnumerable<BattleCardBase> opponentInPlayCards)
{
if (attacker.Attackable)
{
return CanCardAttackTarget(attacker, target, opponentInPlayCards);
}
return false;
}
public static bool IsAttackPossible(AIVirtualCard attacker, AIVirtualCard target, BattlePlayerBase opponent)
{
if (attacker.BaseCard.Attackable)
{
return CanCardAttackTarget(attacker.BaseCard, target.BaseCard, opponent.InPlayCards);
}
return false;
}
}

View File

@@ -0,0 +1,7 @@
using UnityEngine;
public class AudioList : MonoBehaviour
{
[SerializeField]
public string[] GimicAudioList;
}

View File

@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class BMFont
{
[HideInInspector]
[SerializeField]
private int mSize = 16;
[HideInInspector]
[SerializeField]
private int mBase;
[HideInInspector]
[SerializeField]
private int mWidth;
[HideInInspector]
[SerializeField]
private int mHeight;
[HideInInspector]
[SerializeField]
private string mSpriteName;
[HideInInspector]
[SerializeField]
private List<BMGlyph> mSaved = new List<BMGlyph>();
private Dictionary<int, BMGlyph> mDict = new Dictionary<int, BMGlyph>();
public bool isValid => mSaved.Count > 0;
public int charSize
{
get
{
return mSize;
}
set
{
mSize = value;
}
}
public int baseOffset
{
get
{
return mBase;
}
set
{
mBase = value;
}
}
public int texWidth
{
get
{
return mWidth;
}
set
{
mWidth = value;
}
}
public int texHeight
{
get
{
return mHeight;
}
set
{
mHeight = value;
}
}
public int glyphCount
{
get
{
if (!isValid)
{
return 0;
}
return mSaved.Count;
}
}
public string spriteName
{
get
{
return mSpriteName;
}
set
{
mSpriteName = value;
}
}
public List<BMGlyph> glyphs => mSaved;
public BMGlyph GetGlyph(int index, bool createIfMissing)
{
BMGlyph value = null;
if (mDict.Count == 0)
{
int i = 0;
for (int count = mSaved.Count; i < count; i++)
{
BMGlyph bMGlyph = mSaved[i];
mDict.Add(bMGlyph.index, bMGlyph);
}
}
if (!mDict.TryGetValue(index, out value) && createIfMissing)
{
value = new BMGlyph();
value.index = index;
mSaved.Add(value);
mDict.Add(index, value);
}
return value;
}
public BMGlyph GetGlyph(int index)
{
return GetGlyph(index, createIfMissing: false);
}
public void Clear()
{
mDict.Clear();
mSaved.Clear();
}
public void Trim(int xMin, int yMin, int xMax, int yMax)
{
if (isValid)
{
int i = 0;
for (int count = mSaved.Count; i < count; i++)
{
mSaved[i]?.Trim(xMin, yMin, xMax, yMax);
}
}
}
}

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
[Serializable]
public class BMGlyph
{
public int index;
public int x;
public int y;
public int width;
public int height;
public int offsetX;
public int offsetY;
public int advance;
public int channel;
public List<int> kerning;
public int GetKerning(int previousChar)
{
if (kerning != null && previousChar != 0)
{
int i = 0;
for (int count = kerning.Count; i < count; i += 2)
{
if (kerning[i] == previousChar)
{
return kerning[i + 1];
}
}
}
return 0;
}
public void SetKerning(int previousChar, int amount)
{
if (kerning == null)
{
kerning = new List<int>();
}
for (int i = 0; i < kerning.Count; i += 2)
{
if (kerning[i] == previousChar)
{
kerning[i + 1] = amount;
return;
}
}
kerning.Add(previousChar);
kerning.Add(amount);
}
public void Trim(int xMin, int yMin, int xMax, int yMax)
{
int num = x + width;
int num2 = y + height;
if (x < xMin)
{
int num3 = xMin - x;
x += num3;
width -= num3;
offsetX += num3;
}
if (y < yMin)
{
int num4 = yMin - y;
y += num4;
height -= num4;
offsetY += num4;
}
if (num > xMax)
{
width -= num - xMax;
}
if (num2 > yMax)
{
height -= num2 - yMax;
}
}
}

View File

@@ -0,0 +1,93 @@
using System;
using UnityEngine;
[Serializable]
public class BMSymbol
{
public string sequence;
public string spriteName;
private UISpriteData mSprite;
private bool mIsValid;
private int mLength;
private int mOffsetX;
private int mOffsetY;
private int mWidth;
private int mHeight;
private int mAdvance;
private Rect mUV;
public int length
{
get
{
if (mLength == 0)
{
mLength = sequence.Length;
}
return mLength;
}
}
public int offsetX => mOffsetX;
public int offsetY => mOffsetY;
public int width => mWidth;
public int height => mHeight;
public int advance => mAdvance;
public Rect uvRect => mUV;
public void MarkAsChanged()
{
mIsValid = false;
}
public bool Validate(UIAtlas atlas)
{
if (atlas == null)
{
return false;
}
if (!mIsValid)
{
if (string.IsNullOrEmpty(spriteName))
{
return false;
}
mSprite = ((atlas != null) ? atlas.GetSprite(spriteName) : null);
if (mSprite != null)
{
Texture texture = atlas.texture;
if (texture == null)
{
mSprite = null;
}
else
{
mUV = new Rect(mSprite.x, mSprite.y, mSprite.width, mSprite.height);
mUV = NGUIMath.ConvertToTexCoords(mUV, texture.width, texture.height);
mOffsetX = mSprite.paddingLeft;
mOffsetY = mSprite.paddingTop;
mWidth = mSprite.width;
mHeight = mSprite.height;
mAdvance = mSprite.width + (mSprite.paddingLeft + mSprite.paddingRight);
mIsValid = true;
}
}
}
return mSprite != null;
}
}

View File

@@ -0,0 +1,272 @@
using System;
using System.Collections;
using System.Collections.Generic;
using Cute;
using UnityEngine;
using Wizard.Battle.View.Vfx;
public class BackGroundBase
{
protected string _bgmId;
protected BattleCamera _battleCamera;
protected GameObject _fieldModel;
protected GameObject _fieldParticles;
protected IDictionary<string, Animation> m_FieldAnimationDictionary;
protected IDictionary<string, GameObject> _fieldObjDictionary;
protected IDictionary<string, Animator> m_FieldAnimatorDictionary;
protected IDictionary<string, ParticleSystem> _fieldParticleSystemDictionary;
protected IDictionary<string, int> _gimicCntDictionary;
public string[] GimicAudioList;
protected string _str3DFieldNo;
protected string _str3DFieldPath;
protected BattleManagerBase m_BtlMgrIns;
protected string m_FieldAssetPath;
protected List<string> m_SoundAssetPathList;
protected float m_RandomActionTime;
protected bool IsFieldRandom;
private Coroutine battleLoadCoroutine;
public virtual int FieldId => 1;
public virtual int FieldEffectId => FieldId;
public GameObject Field { get; protected set; }
public GameObject m_Battle3DContainer { get; protected set; }
public GameObject m_BattleCutInContainer { get; protected set; }
public SetShaderGlobalColorBG SetShaderGlobalColorBG { get; protected set; }
public bool IsLoadDone { get; protected set; }
public BackGroundBase(string bgmId = "NONE")
{
_battleCamera = null;
m_Battle3DContainer = null;
m_BattleCutInContainer = null;
m_BtlMgrIns = BattleManagerBase.GetIns();
IsLoadDone = false;
_str3DFieldNo = "";
_str3DFieldPath = "";
m_FieldAssetPath = "";
Field = null;
_fieldModel = null;
_fieldParticles = null;
_bgmId = bgmId;
m_RandomActionTime = 0f;
IsFieldRandom = false;
m_SoundAssetPathList = new List<string>();
_fieldObjDictionary = new Dictionary<string, GameObject>();
m_FieldAnimationDictionary = new Dictionary<string, Animation>();
m_FieldAnimatorDictionary = new Dictionary<string, Animator>();
_fieldParticleSystemDictionary = new Dictionary<string, ParticleSystem>();
_gimicCntDictionary = new Dictionary<string, int>();
SetShaderGlobalColorBG = null;
Physics.gravity = new Vector3(0f, 0f, 9.8f);
_str3DFieldNo = GetFieldIdString(FieldId);
_gimicCntDictionary.Add("FieldGimic1", 0);
_gimicCntDictionary.Add("FieldGimic2", 0);
_gimicCntDictionary.Add("FieldGimic3", 0);
}
public void Dispose()
{
UnityEngine.Object.DestroyImmediate(Field);
Field = null;
_fieldModel = null;
_fieldParticles = null;
_fieldObjDictionary.Clear();
m_FieldAnimationDictionary.Clear();
m_FieldAnimatorDictionary.Clear();
_fieldParticleSystemDictionary.Clear();
m_SoundAssetPathList.Clear();
_gimicCntDictionary.Clear();
SetShaderGlobalColorBG = null;
BattleCoroutine.GetInstance().StopCoroutine(battleLoadCoroutine);
}
public void CreateField(BattleCamera battleCamera, GameObject battle3DContainer, GameObject cutInContainer)
{
_battleCamera = battleCamera;
m_Battle3DContainer = battle3DContainer;
m_BattleCutInContainer = cutInContainer;
Camera componentInChildren = m_Battle3DContainer.GetComponentInChildren<Camera>();
Camera component = componentInChildren.transform.Find("Camera 3DGround").GetComponent<Camera>();
_battleCamera.SetUp(componentInChildren, m_BattleCutInContainer.transform.Find("Camera").GetComponent<UICamera>(), component);
LoadField();
}
protected void LoadField()
{
IsLoadDone = false;
m_BtlMgrIns = BattleManagerBase.GetIns();
_str3DFieldNo = GetFieldIdString(FieldEffectId);
_str3DFieldPath = "3DField" + GetFieldIdString(FieldId);
m_SoundAssetPathList.Add($"s/se_field_{_str3DFieldNo}.acb");
m_SoundAssetPathList.Add(string.Format("b/bgm_field_{0}.acb", (_bgmId != "NONE") ? GetFieldIdString(_bgmId) : _str3DFieldNo));
m_SoundAssetPathList.Add(string.Format("b/bgm_field_{0}.awb", (_bgmId != "NONE") ? GetFieldIdString(_bgmId) : _str3DFieldNo));
m_FieldAssetPath = Toolbox.ResourcesManager.GetAssetTypePath(_str3DFieldPath, ResourcesManager.AssetLoadPathType.Field3D);
List<string> additionalAssetList = CollectAdditionalAssets();
GameMgr.GetIns().GetEffectMgr().InitCommonEffect(string.Format("Json/FIeld" + _str3DFieldNo + "EffectData", _str3DFieldNo), isBattle: true);
battleLoadCoroutine = BattleCoroutine.GetInstance().StartCoroutine(Toolbox.ResourcesManager.LoadAssetGroupAsync(m_SoundAssetPathList, delegate
{
BattleCoroutine.GetInstance().StartCoroutine(Toolbox.ResourcesManager.LoadAssetAsync(m_FieldAssetPath, delegate
{
Toolbox.ResourcesManager.BattleListAssetPathList.AddRange(m_SoundAssetPathList);
Toolbox.ResourcesManager.BattleListAssetPathList.Add(m_FieldAssetPath);
(UnityEngine.Object.Instantiate(Toolbox.ResourcesManager.LoadObject(Toolbox.ResourcesManager.GetAssetTypePath(_str3DFieldPath, ResourcesManager.AssetLoadPathType.Field3D, isfetch: true))) as GameObject).name = _str3DFieldPath;
if (additionalAssetList.IsNotNullOrEmpty())
{
BattleCoroutine.GetInstance().StartCoroutine(Toolbox.ResourcesManager.LoadAssetGroupAsync(additionalAssetList, delegate
{
Toolbox.ResourcesManager.BattleListAssetPathList.AddRange(additionalAssetList);
BattleFieldBuild();
}));
}
else
{
BattleFieldBuild();
}
}));
}));
}
private string GetFieldIdString(int fieldId)
{
return fieldId.ToString((fieldId < 100) ? "00" : "0000");
}
private string GetFieldIdString(string fileldId)
{
if (int.TryParse(fileldId, out var result))
{
return result.ToString((result < 100) ? "00" : "0000");
}
return fileldId;
}
protected virtual void BattleFieldBuild()
{
}
protected virtual List<string> CollectAdditionalAssets()
{
return null;
}
public virtual void StartFieldSetEffect(Vector3 pos)
{
}
public virtual void StartFieldTapEffect(int areaId, Vector3 pos)
{
BattleManagerBase.GetIns().BattlePlayer.PlayerBattleView.IsTouchable();
}
public void StartFieldOpening()
{
PlayBgm();
OpeningVfx.OpenningLogStep = "StartFieldOpening";
IsFieldRandom = true;
BattleCoroutine.GetInstance().StartCoroutine(RunFieldOpening());
}
public void PlayBgm()
{
GameMgr.GetIns().GetSoundMgr().PlayBGM(string.Format("bgm_field_{0}", (_bgmId != "NONE") ? GetFieldIdString(_bgmId) : _str3DFieldNo), 0f, 0L);
}
protected virtual IEnumerator RunFieldOpening()
{
yield return new WaitForSeconds(0f);
}
public static IEnumerator ObjectChecker(float fWaitSecs, string strObjectFind, Action callback)
{
while (GameObject.Find(strObjectFind) == null || !GameMgr.GetIns().GetEffectMgr().IsFieldEffectReady || !GameMgr.GetIns().GetEffectMgr().IsBattleUIEffectReady)
{
yield return null;
}
callback();
}
public void StartFieldGimic(GameObject obj)
{
if (!GameMgr.GetIns().IsReplayBattle && BattleManagerBase.GetIns().BattlePlayer.PlayerBattleView.IsTouchable())
{
BattleCoroutine.GetInstance().StartCoroutine(RunFieldGimic(obj));
}
}
protected virtual IEnumerator RunFieldGimic(GameObject obj)
{
yield return new WaitForSeconds(0f);
}
public void StartFieldShake()
{
BattleCoroutine.GetInstance().StartCoroutine(RunFieldShake());
}
protected virtual IEnumerator RunFieldShake()
{
yield return new WaitForSeconds(0f);
}
public virtual void UpdateFieldRandom()
{
}
public void AddParticleToFieldObjDictionary(string targetPath)
{
string[] array = targetPath.Split('/');
List<Transform> list = new List<Transform>();
list.Add(_fieldParticles.transform);
List<Transform> list2 = new List<Transform>();
for (int i = 0; i < array.Length; i++)
{
list2 = new List<Transform>();
for (int j = 0; j < list.Count; j++)
{
list2.AddRange(FindAllChildByName(list[j], array[i]));
}
list = new List<Transform>(list2);
}
for (int k = 0; k < list2.Count; k++)
{
_fieldObjDictionary.Add(targetPath + "_" + k, list2[k].gameObject);
}
}
public List<Transform> FindAllChildByName(Transform parent, string name)
{
List<Transform> list = new List<Transform>();
for (int i = 0; i < parent.childCount; i++)
{
Transform child = parent.GetChild(i);
if (child.name == name)
{
list.Add(child);
}
}
return list;
}
}

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
using Wizard.Battle;
internal class BaseCardIDComp : EqualityComparer<IReadOnlyBattleCardInfo>
{
public override bool Equals(IReadOnlyBattleCardInfo x, IReadOnlyBattleCardInfo y)
{
if (x == y)
{
return true;
}
if (x.BaseParameter.BaseCardId == y.BaseParameter.BaseCardId)
{
return true;
}
return false;
}
public override int GetHashCode(IReadOnlyBattleCardInfo obj)
{
return obj.BaseParameter.BaseCardId;
}
}

View File

@@ -0,0 +1,79 @@
using UnityEngine;
using Wizard.Battle.View.Vfx;
public class BattleCamera
{
public UICamera m_CutInCamera;
public Camera Camera;
public Camera _backgroundCamera;
public Vector3 BattleCameraPos { get; private set; }
public Vector3 BattleCameraRot { get; private set; }
public BattleCamera()
{
Camera = null;
}
public void SetUp(Camera camera, UICamera cutInCamera, Camera backgroundCamera)
{
Camera = camera;
m_CutInCamera = cutInCamera;
_backgroundCamera = backgroundCamera;
BattleCameraPos = Camera.transform.localPosition;
BattleCameraRot = Camera.transform.eulerAngles;
}
public VfxBase ShakeCamera(Vector3 amount, float time, float delay)
{
ParallelVfxPlayer parallelVfxPlayer = ParallelVfxPlayer.Create();
parallelVfxPlayer.Register(InstantVfx.Create(delegate
{
iTween.ShakePosition(Camera.gameObject, iTween.Hash("amount", amount, "time", time, "delay", delay));
}));
return parallelVfxPlayer;
}
public static VfxBase ShakeCameraGameObject(GameObject obj, Vector3 amount, float time, float delay)
{
ParallelVfxPlayer parallelVfxPlayer = ParallelVfxPlayer.Create();
parallelVfxPlayer.Register(InstantVfx.Create(delegate
{
iTween.ShakePosition(obj, iTween.Hash("amount", amount, "time", time, "delay", delay));
}));
return parallelVfxPlayer;
}
public VfxBase ShakeComplete()
{
return InstantVfx.Create(delegate
{
Camera.transform.localPosition = BattleCameraPos;
Camera.transform.eulerAngles = BattleCameraRot;
iTween.Stop(Camera.gameObject);
});
}
public static VfxBase ShakeCompleteGameObject(GameObject obj, Vector3 position, Vector3 euler)
{
return InstantVfx.Create(delegate
{
obj.transform.localPosition = position;
obj.transform.eulerAngles = euler;
iTween.Stop(obj);
});
}
public Camera Get3DCamera()
{
return Camera;
}
public void Dispose()
{
Camera = null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Linq;
using Wizard.Battle;
using Wizard.Battle.View;
public static class BattleCardBaseExtensions
{
public static List<IBattleCardView> ConvertToViewList(this IList<BattleCardBase> battleCardBaseList)
{
return battleCardBaseList?.Select((BattleCardBase c) => c.BattleCardView).ToList();
}
public static BattleCardBase FindFromCardId(this IList<BattleCardBase> battleCardBaseList, IBattleCardUniqueID cardId)
{
if (battleCardBaseList == null)
{
return null;
}
for (int i = 0; i < battleCardBaseList.Count; i++)
{
BattleCardBase battleCardBase = battleCardBaseList[i];
if (battleCardBase.EquelsID(cardId))
{
return battleCardBase;
}
}
return null;
}
}

View File

@@ -0,0 +1,487 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cute;
using UnityEngine;
using Wizard.Battle.View.Vfx;
public class BattleCardIconAnimations : MonoBehaviour
{
public class SkillIcon
{
public string _key;
public string _iconSpriteName;
public int LabelNumber;
public SkillIcon(string key, string iconSpriteName, int labelNumber)
{
_key = key;
_iconSpriteName = iconSpriteName;
LabelNumber = labelNumber;
}
}
private List<SkillIcon> skillIconList = new List<SkillIcon>();
private List<SkillIcon> skillIconListWithoutDuplicates = new List<SkillIcon>();
private CardTemplate cardTemplate;
private BattleCardBase _card;
private SkillCollectionBase collection;
private bool skillIconAlphaFlg;
private int skillCount;
private int _inductionLabelNumber = -1;
private const float ALPHA_BLEND_RATE = 0.6f;
private const string INDUCTION_ICON_SPRITE_NAME = "battle_notice_status_04";
private const string WHITE_RITUAL_STACK_SPRITE_NAME = "battle_notice_status_11";
public VfxBase Initialize(BattleCardBase card, SkillCollectionBase collection, bool isStackWhiteRitual = false)
{
_card = card;
this.collection = collection;
ISkillApplyInformation skillApplyInformation = card.SkillApplyInformation;
bool isEarthRiteField = (IsEarthRiteField() ? true : false);
bool hasInductionSkill = HasInductionSkill();
bool hasInductionNumberSkill = HasInductionNumberSkill();
bool hasKiller = (skillApplyInformation.IsKiller ? true : false);
bool hasDrain = (skillApplyInformation.IsDrain ? true : false);
bool hasWhenDestroySkill = (HasWhenDestroySkill() ? true : false);
bool hasGetonSkill = HasGetonSkill();
bool isGetOnAfter = _card.GetOnCards.Any();
bool hasWhiteRirualStackSkill = HasStackWhiteRitualSkill();
int whiteRitualCount = _card.SkillApplyInformation.WhiteRitualCount;
return InstantVfx.Create(delegate
{
InitializeIcon(isEarthRiteField && !hasWhiteRirualStackSkill, isEarthRiteField && hasWhiteRirualStackSkill, whiteRitualCount, hasInductionSkill, hasInductionNumberSkill, hasKiller, hasDrain, hasWhenDestroySkill, hasGetonSkill, isGetOnAfter, isReplay: false, isStackWhiteRitual);
});
}
public VfxBase InitializeOnlyStack(BattleCardBase card, SkillCollectionBase collection)
{
_card = card;
this.collection = collection;
bool isEarthRiteField = IsEarthRiteField();
bool hasWhiteRirualStackSkill = HasStackWhiteRitualSkill();
int whiteRitualCount = _card.SkillApplyInformation.WhiteRitualCount;
return InstantVfx.Create(delegate
{
InitializeIcon(isEarthRiteField && !hasWhiteRirualStackSkill, isEarthRiteField && hasWhiteRirualStackSkill, whiteRitualCount, hasInductionSkill: false, hasInductionNumberSkill: false, hasKiller: false, hasDrain: false, hasWhenDestroySkill: false, hasGetonSkill: false);
});
}
private void InitializeIcon(bool hasWhiteRirualSkill, bool hasWhiteRirualStackSkill, int whiteRitualCount, bool hasInductionSkill, bool hasInductionNumberSkill, bool hasKiller, bool hasDrain, bool hasWhenDestroySkill, bool hasGetonSkill, bool isGetOnAfter = false, bool isReplay = false, bool isStackWhiteRitual = false)
{
if (!(_card.BattleCardView.GameObject == null))
{
ClearAllSkillIcons();
cardTemplate = _card.BattleCardView.GameObject.GetComponent<CardTemplate>();
cardTemplate.SkillIconTemp.gameObject.transform.localPosition = new Vector3(0f, -30f, -0.1f);
cardTemplate.SkillIconTemp.gameObject.transform.localScale = new Vector3(0.2f, 0.2f, 1f);
cardTemplate.SkillIconTemp.atlas = UIManager.GetInstance().GetAtlasList().FirstOrDefault((UIAtlas s) => s.name == "Battle");
AddToIconList("white_ritual", "battle_notice_status_08", hasWhiteRirualSkill);
AddToIconList("stack_white_ritual", "battle_notice_status_11", hasWhiteRirualStackSkill, whiteRitualCount);
AddToIconList("induction", "battle_notice_status_04", hasInductionSkill);
AddToIconList("induction_number", "battle_notice_status_04", hasInductionNumberSkill, GetInductionLabelNumber());
AddToIconList("killer", "battle_notice_status_01", hasKiller);
AddToIconList("drain", "battle_notice_status_07", hasDrain);
AddToIconList("destroy", "battle_notice_status_06", hasWhenDestroySkill);
if (isReplay)
{
AddToIconList("geton", "battle_notice_status_09", hasGetonSkill);
AddToIconList("geton_after", "battle_notice_status_10", isGetOnAfter);
}
else if (isGetOnAfter)
{
AddToIconList("geton_after", "battle_notice_status_10", hasGetonSkill);
}
else
{
AddToIconList("geton", "battle_notice_status_09", hasGetonSkill);
}
PopulateSkillIconListWithoutDuplicates();
string spriteName = (skillIconListWithoutDuplicates.Any() ? skillIconListWithoutDuplicates[0]._iconSpriteName : string.Empty);
cardTemplate.SkillIconTemp.spriteName = spriteName;
ChangeSkillIconLabel(cardTemplate.SkillIconLabelTemp, skillIconListWithoutDuplicates.Any() ? skillIconListWithoutDuplicates[0].LabelNumber : (-1));
UpdateSkillIconLabelColor();
skillCount = 0;
cardTemplate.SkillIconTemp.gameObject.SetActive(value: true);
if (isStackWhiteRitual)
{
cardTemplate.SkillIconTemp.alpha = 1.5f;
skillIconAlphaFlg = false;
}
}
}
public VfxBase UpdateLabelNumber()
{
return InstantVfx.Create(delegate
{
SkillIcon skillIcon = skillIconListWithoutDuplicates.FirstOrDefault((SkillIcon i) => i._key == "induction_number");
if (skillIcon != null)
{
skillIcon.LabelNumber = GetInductionLabelNumber();
if (cardTemplate.SkillIconTemp.spriteName == "battle_notice_status_04")
{
ChangeSkillIconLabel(cardTemplate.SkillIconLabelTemp, skillIcon.LabelNumber);
}
}
});
}
public VfxBase UpdateWhiteRitualCountLabel()
{
if (!HasStackWhiteRitualSkill() || !IsEarthRiteField())
{
return NullVfx.GetInstance();
}
int whiteRitualCount = _card.SkillApplyInformation.WhiteRitualCount;
return InstantVfx.Create(delegate
{
SkillIcon skillIcon = skillIconListWithoutDuplicates.FirstOrDefault((SkillIcon i) => i._key == "stack_white_ritual");
if (skillIcon != null)
{
skillIcon.LabelNumber = whiteRitualCount;
if (cardTemplate.SkillIconTemp.spriteName == "battle_notice_status_11")
{
ChangeSkillIconLabel(cardTemplate.SkillIconLabelTemp, skillIcon.LabelNumber);
}
}
});
}
private void AddToIconList(string key, string spriteName, bool addCondition, int labelNumber = -1)
{
if (addCondition)
{
AddSkillIcon(key, spriteName, labelNumber);
}
}
private void PopulateSkillIconListWithoutDuplicates()
{
AddToIconListWithoutDuplicates("white_ritual");
AddToIconListWithoutDuplicates("stack_white_ritual");
AddToIconListWithoutDuplicates("induction");
AddToIconListWithoutDuplicates("induction_number");
AddToIconListWithoutDuplicates("destroy");
AddToIconListWithoutDuplicates("killer");
AddToIconListWithoutDuplicates("drain");
AddToIconListWithoutDuplicates("geton");
}
private void AddToIconListWithoutDuplicates(string key)
{
if (skillIconList.Any((SkillIcon c) => c._key == key) && !skillIconListWithoutDuplicates.Any((SkillIcon c) => c._key == key))
{
SkillIcon skillIcon = skillIconList.SingleOrDefault((SkillIcon c) => c._key == key && c._iconSpriteName != null);
skillIconListWithoutDuplicates.Add(new SkillIcon(key, skillIcon._iconSpriteName, skillIcon.LabelNumber));
}
}
public VfxBase ShowIcon()
{
return InstantVfx.Create(delegate
{
cardTemplate.SkillIconTemp.gameObject.SetActive(value: true);
});
}
public VfxBase HideIcon()
{
return InstantVfx.Create(delegate
{
cardTemplate.SkillIconTemp.gameObject.SetActive(value: false);
});
}
private void Update()
{
if (cardTemplate != null)
{
if (cardTemplate.SkillIconTemp.gameObject.activeSelf)
{
SkillIconAlphaBlend();
}
else
{
cardTemplate.SkillIconTemp.alpha = 0f;
}
}
}
public void AddSkillIcon(string key, string fileName, int labelNumber = -1)
{
string iconSpriteName = ((!skillIconList.Any((SkillIcon v) => v._key == key)) ? fileName : null);
skillIconList.Add(new SkillIcon(key, iconSpriteName, labelNumber));
skillIconListWithoutDuplicates = skillIconList.Where((SkillIcon v) => v._iconSpriteName != null).ToList();
}
public void DeleteSkillIcon(string key)
{
if (skillIconList.Any((SkillIcon v) => v._key == key))
{
skillIconList.Remove(skillIconList.Where((SkillIcon v) => v._key == key).Last());
}
skillIconListWithoutDuplicates = skillIconList.Where((SkillIcon v) => v._iconSpriteName != null).ToList();
if (skillIconListWithoutDuplicates.Count == 0)
{
cardTemplate.SkillIconTemp.spriteName = string.Empty;
cardTemplate.SkillIconLabelTemp.text = string.Empty;
}
ChangeTexture();
}
public void DeleteUnneededSkillIcons()
{
RemoveSkillIconFromList("white_ritual", () => !IsEarthRiteField());
RemoveSkillIconFromList("induction", () => !HasInductionSkill());
RemoveSkillIconFromList("induction_number", () => !HasInductionNumberSkill());
RemoveSkillIconFromList("destroy", () => !HasWhenDestroySkill());
}
private void RemoveSkillIconFromList(string key, Func<bool> deleteCondition)
{
if (deleteCondition())
{
DeleteSkillIcon(key);
}
}
private void ChangeTexture()
{
if (skillIconListWithoutDuplicates.Count() - 1 > skillCount)
{
skillCount++;
cardTemplate.SkillIconTemp.spriteName = skillIconListWithoutDuplicates[skillCount]._iconSpriteName;
ChangeSkillIconLabel(cardTemplate.SkillIconLabelTemp, skillIconListWithoutDuplicates[skillCount].LabelNumber);
}
else if (skillIconListWithoutDuplicates.Count() != 0)
{
skillCount = 0;
cardTemplate.SkillIconTemp.spriteName = skillIconListWithoutDuplicates[skillCount]._iconSpriteName;
ChangeSkillIconLabel(cardTemplate.SkillIconLabelTemp, skillIconListWithoutDuplicates[skillCount].LabelNumber);
}
UpdateSkillIconLabelColor();
}
private void UpdateSkillIconLabelColor()
{
if (cardTemplate.SkillIconTemp.spriteName == "battle_notice_status_11")
{
cardTemplate.SkillIconLabelTemp.color = Color.white;
cardTemplate.SkillIconLabelTemp.effectColor = Color.black;
}
else if (cardTemplate.SkillIconTemp.spriteName == "battle_notice_status_04")
{
cardTemplate.SkillIconLabelTemp.color = Color.black;
cardTemplate.SkillIconLabelTemp.effectColor = Color.white;
}
}
private void ChangeSkillIconLabel(UILabel label, int labelNumber)
{
if (labelNumber == -1)
{
label.text = string.Empty;
}
else
{
label.text = labelNumber.ToString();
}
}
public void ClearAllSkillIcons()
{
skillIconList.Clear();
skillIconListWithoutDuplicates.Clear();
}
private void SkillIconAlphaBlend()
{
bool flag = cardTemplate.SkillIconLabelTemp.text.IsNotNullOrEmpty();
if (skillIconListWithoutDuplicates.Count > 1)
{
if (skillIconAlphaFlg)
{
cardTemplate.SkillIconTemp.alpha += (flag ? (0.6f * Time.deltaTime * 2f) : (0.6f * Time.deltaTime));
}
else
{
cardTemplate.SkillIconTemp.alpha -= (flag ? (0.6f * Time.deltaTime * 2f) : (0.6f * Time.deltaTime));
}
}
else if (cardTemplate.SkillIconTemp.spriteName == string.Empty)
{
cardTemplate.SkillIconTemp.alpha = 1f;
if (skillIconListWithoutDuplicates.Count > 0)
{
if (cardTemplate.SkillIconTemp.spriteName != skillIconListWithoutDuplicates[0]._iconSpriteName)
{
cardTemplate.SkillIconTemp.spriteName = skillIconListWithoutDuplicates[0]._iconSpriteName;
}
ChangeSkillIconLabel(cardTemplate.SkillIconLabelTemp, skillIconListWithoutDuplicates[0].LabelNumber);
UpdateSkillIconLabelColor();
}
}
else
{
cardTemplate.SkillIconTemp.alpha = 1f;
}
if (skillIconAlphaFlg && cardTemplate.SkillIconTemp.alpha >= (flag ? 2f : 1f))
{
skillIconAlphaFlg = false;
}
else if (!skillIconAlphaFlg && cardTemplate.SkillIconTemp.alpha <= 0f)
{
ChangeTexture();
skillIconAlphaFlg = true;
}
}
private bool HasWhenDestroySkill()
{
return collection._skillTimingInfo.IsWhenDestroy;
}
public bool HasInductionSkill()
{
for (int i = 0; i < collection.Count(); i++)
{
SkillBase skillBase = collection.ElementAt(i);
if (skillBase.IsInductionSkill && skillBase.SkillPrm.buildInfo._icon == "induction")
{
return true;
}
}
return false;
}
public bool HasStackWhiteRitualSkill()
{
return collection.Any((SkillBase x) => x is Skill_stack_white_ritual);
}
public bool HasGetonSkill()
{
return collection.Any((SkillBase x) => x is Skill_geton);
}
public bool HasInductionNumberSkill()
{
for (int i = 0; i < collection.Count(); i++)
{
SkillBase skillBase = collection.ElementAt(i);
if (skillBase.IsInductionSkill && skillBase.SkillPrm.buildInfo._icon != "induction" && skillBase.SkillPrm.buildInfo._icon.Contains("induction"))
{
return true;
}
}
return false;
}
public int GetInductionLabelNumber()
{
if (_inductionLabelNumber != -1)
{
return _inductionLabelNumber;
}
SkillBase skillBase = collection.FirstOrDefault((SkillBase s) => s.IsInductionSkill && s.SkillPrm.buildInfo._icon != "induction" && s.SkillPrm.buildInfo._icon.Contains("induction"));
if (skillBase == null)
{
return -1;
}
SkillOptionValue skillOptionValue = new SkillOptionValue(skillBase.SkillPrm.buildInfo._icon);
skillOptionValue.SetupFilterVariable(BattleManagerBase.GetIns().GetBattlePlayerInfoPair(_card.IsPlayer), _card, isPrePlay: false, null);
return skillOptionValue.GetInt(SkillFilterCreator.ContentKeyword.induction);
}
private bool IsEarthRiteField()
{
if (_card.IsField || _card.IsChantField)
{
return _card.IsTribe(CardBasePrm.TribeType.WHITE_RITUAL);
}
return false;
}
public VfxBase UpdateSkillIconInReplay(List<NetworkBattleReceiver.InplaySkillEffect> inplaySkillEffectList, int inductionNumber, bool isInitialize, bool isStackWhiteRitual = false)
{
if (!isInitialize && _card.HasStackWhiteRitualAndOtherIconSkill() && skillIconListWithoutDuplicates.Count < 2)
{
return NullVfx.GetInstance();
}
_inductionLabelNumber = inductionNumber;
bool hasWhiteRitualSkill = inplaySkillEffectList.Contains(NetworkBattleReceiver.InplaySkillEffect.WhiteRitual);
bool hasWhiteRirualStackSkill = inplaySkillEffectList.Contains(NetworkBattleReceiver.InplaySkillEffect.StackWhiteRitual);
bool hasInductionSkill = inplaySkillEffectList.Contains(NetworkBattleReceiver.InplaySkillEffect.Induction);
bool hasInductionNumberSkill = inplaySkillEffectList.Contains(NetworkBattleReceiver.InplaySkillEffect.InductionNumber);
bool hasKiller = inplaySkillEffectList.Contains(NetworkBattleReceiver.InplaySkillEffect.Killer);
bool hasDrain = inplaySkillEffectList.Contains(NetworkBattleReceiver.InplaySkillEffect.Drain);
bool hasWhenDestroySkill = inplaySkillEffectList.Contains(NetworkBattleReceiver.InplaySkillEffect.Destroy);
bool hasGeton = inplaySkillEffectList.Contains(NetworkBattleReceiver.InplaySkillEffect.Geton);
bool hasGetonAfter = inplaySkillEffectList.Contains(NetworkBattleReceiver.InplaySkillEffect.GetonAfter);
int whiteRitualCount = _card.SkillApplyInformation.WhiteRitualCount;
return InstantVfx.Create(delegate
{
if (skillIconList.Count == 0 || isInitialize)
{
InitializeIcon(hasWhiteRitualSkill, hasWhiteRirualStackSkill, whiteRitualCount, hasInductionSkill, hasInductionNumberSkill, hasKiller, hasDrain, hasWhenDestroySkill, hasGeton, hasGetonAfter, isReplay: true, isStackWhiteRitual);
}
else
{
UpdateSkillIcon("white_ritual", "battle_notice_status_08", hasWhiteRitualSkill);
UpdateSkillIcon("stack_white_ritual", "battle_notice_status_11", hasWhiteRirualStackSkill, whiteRitualCount);
UpdateSkillIcon("induction", "battle_notice_status_04", hasInductionSkill);
UpdateSkillIcon("induction_number", "battle_notice_status_04", hasInductionNumberSkill, GetInductionLabelNumber());
UpdateSkillIcon("killer", "battle_notice_status_01", hasKiller);
UpdateSkillIcon("drain", "battle_notice_status_07", hasDrain);
UpdateSkillIcon("destroy", "battle_notice_status_06", hasWhenDestroySkill);
UpdateSkillIcon("geton", "battle_notice_status_09", hasGeton);
UpdateSkillIcon("geton_after", "battle_notice_status_10", hasGetonAfter);
}
});
}
private void UpdateSkillIcon(string key, string spriteName, bool hasIcon, int labelNumber = -1)
{
if (hasIcon && !skillIconList.Any((SkillIcon v) => v._key == key))
{
AddToIconList(key, spriteName, hasIcon, labelNumber);
}
else if (!hasIcon && skillIconList.Any((SkillIcon v) => v._key == key))
{
DeleteSkillIcon(key);
}
}
public void DeleteSkillIcons()
{
if (!(cardTemplate == null))
{
DeleteSkillIcon("white_ritual");
DeleteSkillIcon("stack_white_ritual");
DeleteSkillIcon("induction");
DeleteSkillIcon("induction_number");
DeleteSkillIcon("destroy");
DeleteSkillIcon("killer");
DeleteSkillIcon("drain");
DeleteSkillIcon("geton");
}
}
public int GetIconListCount()
{
return skillIconListWithoutDuplicates.Count;
}
}

View File

@@ -0,0 +1,124 @@
using System;
using System.Collections;
using System.Collections.Generic;
using Cute;
using UnityEngine;
using Wizard;
using Wizard.Battle.UI;
using Wizard.Battle.View.Vfx;
public class BattleControl : MonoBehaviour
{
private BattleManagerBase m_BtlMgrIns;
private int FirstAttack;
public void Init()
{
m_BtlMgrIns = BattleManagerBase.GetIns();
GameMgr.GetIns().GetInputMgr().SetLayerMask(512);
LocalLog.AccumulateLastTraceLog("StartBattleCoroutine ");
StartCoroutine(WaitLoadOpponentObjectToBattleStart(m_BtlMgrIns.LoadOpponentObjects()));
}
private IEnumerator WaitLoadOpponentObjectToBattleStart(VfxBase vfx)
{
if (GameMgr.GetIns().IsNetworkBattle)
{
FirstAttack = ToolboxGame.RealTimeNetworkAgent.GetIsFirstPlayer();
}
while (!vfx.IsEnd)
{
yield return null;
}
LocalLog.AccumulateLastTraceLog("DecideFirstUser End ");
m_BtlMgrIns.StartOpening(FirstAttack);
}
public void BattleEnd(UIManager.ViewScene MoveTo, Action callback = null, Action<UIManager.ChangeViewSceneParam> paramCustomize = null, object sceneParam = null)
{
ToolboxGame.UIManager.gameObject.SetActive(value: true);
UIManager.ChangeViewSceneParam changeViewSceneParam = new UIManager.ChangeViewSceneParam();
changeViewSceneParam.OnBeforeChange = delegate
{
BattleManagerBase.GetIns().DisposeBattleGameObj();
};
changeViewSceneParam.OnChange = delegate
{
GameMgr.GetIns().GetEffectMgr().DestroyBattleEffectContainer();
GameMgr.GetIns().GetDataMgr().ResetEnemyData();
GameMgr.GetIns().DestroyBattleManagements();
GameMgr.GetIns().GetGameObjMgr().GetUIContainer()
.SetActive(value: false);
if (callback != null)
{
callback();
}
};
paramCustomize.Call(changeViewSceneParam);
StartCoroutine(UnloadAllResources(MoveTo, changeViewSceneParam, null, sceneParam));
}
private IEnumerator UnloadAllResources(UIManager.ViewScene MoveTo = UIManager.ViewScene.None, UIManager.ChangeViewSceneParam param = null, Action callback = null, object sceneParam = null)
{
BattleLogManager.GetInstance().Clear();
GameMgr.GetIns().GetEffectMgr().ClearLastCacheEffect();
StopAllTweens();
yield return Resources.UnloadUnusedAssets();
GC.Collect();
callback?.Invoke();
if (MoveTo != UIManager.ViewScene.None)
{
UIManager.GetInstance().ChangeViewScene(MoveTo, param, sceneParam);
}
}
public IEnumerator BattleEnd(Action callback = null)
{
BattleRelease();
yield return Resources.UnloadUnusedAssets();
GC.Collect();
callback?.Invoke();
}
public void BattleRelease()
{
ToolboxGame.UIManager.gameObject.SetActive(value: true);
GameMgr.GetIns().GetEffectMgr().DestroyBattleEffectContainer();
GameMgr.GetIns().GetDataMgr().ResetEnemyData();
if (BattleManagerBase.GetIns() != null)
{
BattleManagerBase.GetIns().DisposeBattleGameObj();
}
GameMgr.GetIns().DestroyBattleManagements();
GameMgr.GetIns().GetGameObjMgr().GetUIContainer()
.SetActive(value: false);
BattleLogManager.GetInstance().Clear();
GameMgr.GetIns().GetEffectMgr().ClearLastCacheEffect();
StopAllTweens();
}
private void StopAllTweens()
{
HashSet<GameObject> hashSet = new HashSet<GameObject>();
for (int i = 0; i < iTween.tweens.Count; i++)
{
if (iTween.tweens[i] != null)
{
GameObject gameObject = (GameObject)iTween.tweens[i]["target"];
if (gameObject != null)
{
hashSet.Add(gameObject);
}
}
}
foreach (GameObject item in hashSet)
{
if (item != null)
{
iTween.Stop(item);
}
}
iTween.tweens.Clear();
}
}

View File

@@ -0,0 +1,52 @@
using System.Collections;
using UnityEngine;
public class BattleCoroutine
{
private static BattleCoroutine m_instance;
private static MonoBehaviour _coroutineObject;
public static BattleCoroutine GetInstance()
{
if (m_instance == null)
{
m_instance = new BattleCoroutine();
}
if (_coroutineObject == null)
{
GameObject gameObject = Object.Instantiate(Resources.Load("Prefab/Game/_BattleCoroutine")) as GameObject;
if (null != gameObject)
{
_coroutineObject = gameObject.GetComponent<MonoBehaviour>();
}
}
return m_instance;
}
public Coroutine StartCoroutine(IEnumerator enumerator)
{
return _coroutineObject.StartCoroutine(enumerator);
}
public void StopAllCoroutines()
{
_coroutineObject.StopAllCoroutines();
}
public void StopCoroutine(IEnumerator enumerator)
{
if (enumerator != null)
{
_coroutineObject.StopCoroutine(enumerator);
}
}
public void StopCoroutine(Coroutine enumerator)
{
if (enumerator != null)
{
_coroutineObject.StopCoroutine(enumerator);
}
}
}

View File

@@ -0,0 +1,246 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cute;
using UnityEngine;
using Wizard;
using Wizard.Battle;
using Wizard.Battle.Player.Emotion;
using Wizard.Battle.View;
using Wizard.Battle.View.Vfx;
public class BattleEnemy : BattlePlayerBase
{
private readonly Vector3 OFFSET_THINK_ICON_FROM_CLASSVIEW = new Vector3(0.62f, 0.15f, 0f);
private IEmotion _emotion;
private readonly Vector3 FIELD_CENTER_POSITION = new Vector3(0f, 0.25f, 0f);
public override bool IsGameFirst => !base.BattleMgr.IsFirst;
public override bool IsPlayer => false;
public override IBattlePlayerView BattleView => BattleEnemyView;
public override IEmotion Emotion => _emotion;
public virtual IBattlePlayerView BattleEnemyView { get; protected set; }
public bool EnableEnemyAI { get; set; }
public override int Turn
{
get
{
if (!base.BattleMgr.IsFirst)
{
return base.BattleMgr.FirstTurn;
}
return base.BattleMgr.SecondTurn;
}
set
{
if (base.BattleMgr.IsFirst)
{
base.BattleMgr.SecondTurn = value;
}
else
{
base.BattleMgr.FirstTurn = value;
}
}
}
public event Action<List<int>> OnMulliganEndForReplay;
public BattleEnemy(BattleManagerBase battleMgr, BattleCamera battleCamera, BackGroundBase backGround, IInnerOptionsBuilder innerOptionsBuilder)
: base(battleMgr, battleCamera, backGround, innerOptionsBuilder)
{
}
protected override void Initialize()
{
BattleEnemyView = new BattleEnemyView(this);
}
protected override void CreateSelfBattleCard()
{
EnemyClassBattleCard item = new EnemyClassBattleCard(new ClassBattleCardBase.ClassBuildInfo(_isPlayer: false, 20, this, base.BattleMgr.BattlePlayer, base.BattleMgr, base.BattleMgr.BattleResourceMgr));
base.ClassAndInPlayCardList.Add(item);
}
public override void Setup(BattlePlayerBase opponentBattlePlayer)
{
_emotion = _innerOptionsBuilder.CreateEnemyEmotion((IClassBattleCardView)base.Class.BattleCardView);
base.Setup(opponentBattlePlayer);
}
public override void SetupClone(BattlePlayerBase sourceBattlePlayer, BattlePlayerBase virtualOpponentBattlePlayer, CloneActualFlags cloneFlags)
{
sourceBattlePlayer.CopyToVirtualBase(this, virtualOpponentBattlePlayer, cloneFlags);
}
public override VfxBase StartTurnControl(string log = "")
{
if (GameMgr.GetIns().IsAdminWatch)
{
UpdateHandCardsPlayability();
}
Turn++;
SequentialVfxPlayer sequentialVfxPlayer = TurnEvolveControl(BattleView.EpIcon);
VfxBase vfx = TurnStart();
sequentialVfxPlayer.Register(vfx);
VfxBase vfx2 = BattleManagerBase.GetIns().JudgeBattleResult();
sequentialVfxPlayer.Register(vfx2);
sequentialVfxPlayer.Register(CreateThinkingVfx(base.BattleMgr));
return sequentialVfxPlayer;
}
public VfxBase CreateThinkingVfx(BattleManagerBase battleMgr)
{
if (GameMgr.GetIns().IsAdminWatch)
{
return NullVfx.GetInstance();
}
return new DelaySetupVfx(() => new ThinkIconShowVfx(delegate
{
Vector3 position = base.BattleCamera.Get3DCamera().WorldToScreenPoint(base.Class.BattleCardView.Transform.position + OFFSET_THINK_ICON_FROM_CLASSVIEW);
return UIManager.GetInstance().getCamera().ScreenToWorldPoint(position);
}, battleMgr.BattleResourceMgr));
}
public override VfxBase UsePp(int pp, bool isNewReplayMoveTurn = false)
{
base.UsePp(pp);
int usedPp = base.Pp;
int maxPp = base.PpTotal;
Vector3 labelPosition = default(Vector3);
SequentialVfxPlayer sequentialVfxPlayer = SequentialVfxPlayer.Create();
sequentialVfxPlayer.Register(InstantVfx.Create(delegate
{
Vector3 position = base.BattleCamera.Get3DCamera().WorldToScreenPoint(StatusPanelControl.GetPPPanel().transform.Find("PPIcon/PPLabel").transform.position);
labelPosition = UIManager.GetInstance().getCamera().ScreenToWorldPoint(position);
}));
sequentialVfxPlayer.Register(new DelaySetupVfx(() => m_vfxCreator.CreateUsePp(usedPp, maxPp, labelPosition, isNewReplayMoveTurn)));
return sequentialVfxPlayer;
}
protected override VfxBase TurnStartDrawCard(SkillProcessor skillProcessor)
{
NullVfx.GetInstance();
int drawCount = ((IsGameFirst || Turn != 1) ? 1 : 2);
VfxWith<IEnumerable<BattleCardBase>> vfxWith = RandomCardDraw(drawCount, skillProcessor);
VfxBase vfxBase = CardDrawVfx(vfxWith.Value);
SequentialVfxPlayer result = SequentialVfxPlayer.Create(vfxWith.Vfx, vfxBase);
if (!base.Class.IsDead && EnableEnemyAI)
{
base.BattleMgr.EnemyAI.ExecuteEnemyAI(useWait: true);
}
_ = base.Class.IsDead;
return result;
}
public override VfxBase CardDrawVfx(IEnumerable<BattleCardBase> DrawList, bool skipShuffle = false, bool isOpenDrawSkill = false)
{
SequentialVfxPlayer sequentialVfxPlayer = SequentialVfxPlayer.Create();
if (GameMgr.GetIns().IsAdminWatch)
{
foreach (BattleCardBase card in DrawList)
{
if (card.BaseCost != card.Cost)
{
List<int> costList = card.BattleCardView.GetUseCostList(card.Cost);
sequentialVfxPlayer.Register(InstantVfx.Create(delegate
{
card.BattleCardView.UpdateCost(costList);
}));
}
}
}
sequentialVfxPlayer.Register(new OpponentDrawCardVfx(DrawList, isOpenDrawSkill));
sequentialVfxPlayer.Register(new OpponentDrawCardToHandVfx(DrawList.ToList(), 0.4f, isOpenDrawSkill, skipShuffle));
return sequentialVfxPlayer;
}
public override VfxBase TurnEnd()
{
ParallelVfxPlayer result = ParallelVfxPlayer.Create(base.TurnEnd(), new ThinkIconHideVfx(base.BattleMgr.BattleResourceMgr));
if (GameMgr.GetIns().IsAdminWatch)
{
foreach (BattleCardBase handCard in base.HandCardList)
{
handCard.BattleCardView.HideCanPlayEffect();
}
}
return result;
}
protected override void SetActive()
{
if (GameMgr.GetIns().IsAdminWatch)
{
UpdateHandCardsPlayability();
}
if (!IsGameFirst || Turn != 1)
{
base.IsChoiceBraveEffectTiming = true;
BattleEnemyView.UpdateChoiceBraveButtonPulsateEffectAndSprite();
}
}
public override BattlePlayerBase CreateVirtualPlayer()
{
return new VirtualBattleEnemy(base.BattleMgr, base.BattleCamera, base.BackGround);
}
public override void UpdateHandCardsPlayability(bool areArrowsForcedOff = false)
{
foreach (BattleCardBase handCard in _opponentBattlePlayer.HandCardList)
{
handCard.BattleCardView.areArrowsForcedOff = areArrowsForcedOff;
handCard.BattleCardView.UpdateMovability();
}
if (!GameMgr.GetIns().IsAdmin)
{
return;
}
foreach (BattleCardBase handCard2 in base.HandCardList)
{
handCard2.BattleCardView.areArrowsForcedOff = areArrowsForcedOff;
handCard2.BattleCardView.UpdateMovability();
}
if (base.IsSelfTurn)
{
BattleView.UpdateChoiceBraveButtonPulsateEffectAndSprite();
}
}
public override VfxBase MoveToHand(List<BattleCardBase> cardsToMoveToHand)
{
return SequentialVfxPlayer.Create(new OpponentDrawCardToHandVfx(cardsToMoveToHand.ToList(), 0.3f), InstantVfx.Create(delegate
{
UpdateHandCardsPlayability();
}));
}
public override EffectBattle GetSkillEffect(string skillEffectPath)
{
return GameMgr.GetIns().GetEffectMgr().GetEnemyEffectBattle(skillEffectPath);
}
public override Vector3 GetFieldCenterPosition()
{
return FIELD_CENTER_POSITION;
}
public override VfxBase TurnStartDraw(SkillProcessor skillProcessor)
{
return base.TurnStartDraw(skillProcessor);
}
public void CallRecordingMulliganEnd(List<int> cardIndexList)
{
this.OnMulliganEndForReplay.Call(cardIndexList);
}
}

Some files were not shown because too many files have changed in this diff Show More