Compare commits

..
156 Commits
Author SHA1 Message Date
Hide_D 20bd059e96 test(compare): 월 이벤트 장수 turntime 입력을 논리 tick으로 맞춘다 2026-08-24 09:07:37 +00:00
Hide_D ea54ffbbff merge: 전투 차등 계측 보강을 ng_compare에 반영한다 2026-08-24 04:10:37 +00:00
Hide_D a0b6a48a51 test: 전투 차등 계측 범위를 엄격하게 보강한다
fixture 시각을 DB 변경 없이 고정하고 활성 스킬과 fixture identity를 노출해 Core의 전체 상태·RNG·로그 비교가 첫 차이에서 실패하도록 한다.
2026-08-24 03:35:39 +00:00
Hide_D a041a8132f test: 커맨드 차등 스냅샷과 수명주기 계측을 보강
명령 상태의 자료형과 로그, 메시지, 순위 그래프를 보존하고 실제 명령 호출을 관찰하는 비교 전용 trace를 추가합니다.

계측은 CLI 및 명시적 차등 테스트 환경에서만 실행되며 제품 동작은 변경하지 않습니다.
2026-08-23 21:49:10 +00:00
Hide_D e2033637e3 merge: 대규모 이민족 소환 비교 fixture를 반영한다 2026-08-23 12:03:22 +00:00
Hide_D 3d5504bbe1 merge: 최신 devel 이민족 소환 수정을 비교 브랜치에 반영한다 2026-08-23 12:03:17 +00:00
Hide_D 6c7f774fa1 merge: 대규모 이민족 소환 중단 수정을 반영한다 2026-08-23 12:01:49 +00:00
Hide_D 5a7ab4d5ce test(compare): 대규모 이민족 소환 시간 경계를 재현한다 2026-08-23 12:01:30 +00:00
Hide_D c49f6008a6 fix: 대규모 이민족 소환 중단을 방지한다 2026-08-23 12:01:17 +00:00
Hide_D 857778e3cf merge(compare): 국가 사령턴 수명주기 계측을 반영 2026-08-16 11:36:01 +00:00
Hide_D 7c96d238ca test(compare): 국가 사령턴 수명주기 계측을 확장 2026-08-16 11:34:57 +00:00
Hide_D 41a42e4413 merge(compare): preserve nullable monthly snapshots 2026-08-15 09:45:24 +00:00
Hide_D 04caf67851 test(compare): preserve nullable monthly snapshot fields 2026-08-15 09:40:39 +00:00
Hide_D 4eca886d61 merge: add browser invader resume boundary 2026-08-12 16:20:49 +00:00
Hide_D 5b829dbb3e test: drive invader resume from browser boundary 2026-08-12 16:20:44 +00:00
Hide_D 39cf6f4ede merge: cover invader game resume 2026-08-12 15:42:29 +00:00
Hide_D 392d8e4042 merge: resume invader games after unification 2026-08-12 15:42:22 +00:00
Hide_D 0d990c090a test(compare): enforce invader resume watchdog 2026-08-12 15:40:07 +00:00
Hide_D 90ed246e2a merge: validate invader resume watchdog fix 2026-08-12 15:35:57 +00:00
Hide_D 1721e5f6d4 test(compare): reproduce invader game resume 2026-08-12 15:35:52 +00:00
Hide_D c0e776c88d fix: keep invader game reset watchdog active 2026-08-12 15:35:39 +00:00
Hide_D e3fa195d0c fix: exclude synchronized dex from inheritance 2026-08-11 08:13:03 +00:00
Hide_D bf3725e040 fix(auth): resume Kakao OTP during auto login 2026-08-08 04:41:16 +00:00
Hide_D 504b4cda69 fix: require stopped server for picture migration 2026-08-08 02:39:31 +00:00
Hide_D d9ad14c027 fix: support closed-server picture migration 2026-08-07 16:44:02 +00:00
Hide_D dc56ee1c06 fix: project troop times for game clock responses 2026-08-07 16:35:59 +00:00
Hide_D 463554654c fix: select game server for picture migration 2026-08-07 16:26:56 +00:00
Hide_D 5317cb306e fix: preserve image servers in dynasty records 2026-08-07 16:18:27 +00:00
Hide_D db9c7ea828 fix: allow remote icon paths in generals 2026-08-07 16:01:32 +00:00
Hide_D f3bf490df3 fix: expose safe remote upload errors 2026-08-07 14:59:44 +00:00
Hide_D eeb931d3f0 feat: upload editor images to image service 2026-08-06 15:53:06 +00:00
Hide_D b095fb4bf1 feat: add flagged remote user icon upload 2026-08-06 15:43:44 +00:00
Hide_D 234ec919bc feat: add signed image repository sync 2026-08-06 14:50:08 +00:00
Hide_D a953ab4154 Merge branch 'fix/game-clock-reserved-open-20260806' into devel 2026-08-06 02:58:45 +00:00
Hide_D 56a023e195 fix: preserve reserved and wall-clock server info 2026-08-06 02:56:43 +00:00
Hide_D 6c8aa3ed9f merge: trace scenario 2400 speciality parity 2026-08-05 15:22:05 +00:00
Hide_D 69fb122ab1 Merge remote-tracking branch 'origin/devel' into devel 2026-08-05 13:30:34 +00:00
Hide_D 191bb6428a test: trace scenario 2400 parity 2026-08-05 13:25:58 +00:00
Hide_D f9b5733fb8 fix: stabilize legacy general processing order 2026-08-05 12:43:16 +00:00
Hide_D ba7676e0f6 fix: order speciality assignment by general id 2026-08-05 07:20:16 +00:00
Hide_D 66e2e28df2 fix: order speciality assignment by general id 2026-08-05 07:20:06 +00:00
Hide_D 5e279eb666 test: capture legacy battle parity fixtures 2026-08-05 03:08:04 +00:00
Hide_D 605fd3c9d1 merge: scenario 2400 comparison tracing 2026-08-04 18:19:59 +00:00
Hide_D 5d897105ed test: trace scenario 2400 monthly parity 2026-08-04 18:18:48 +00:00
Hide_D 5432590591 merge: refresh ng_compare for logical game clock 2026-08-04 12:09:58 +00:00
Hide_D bee18f20d0 Merge branch 'devel' into compare/scenario2601-monthly-seed-parity-20260802 2026-08-03 18:57:04 +00:00
Hide_D f16a6b6880 test(compare): trace deterministic monthly seed progression 2026-08-03 18:57:01 +00:00
Hide_D 3b2fd1082f merge: scenario 2601 GUI comparison fixture 2026-08-02 08:22:39 +00:00
Hide_D faab4561f6 test(compare): prepare conditional GUI fixtures 2026-08-02 08:22:31 +00:00
Hide_D dcddea15b4 Merge branch compare/npc-possession-differential-20260731 2026-07-31 04:39:48 +00:00
Hide_D 90d078c100 test(compare): trace NPC possession selection 2026-07-31 04:39:00 +00:00
Hide_D 765dee831c Merge branch devel into NPC possession comparison 2026-07-31 03:53:22 +00:00
Hide_D 10c4814f34 Merge remote-tracking branch 'origin/devel' into compare/scenario-effects 2026-07-30 18:38:44 +00:00
Hide_D fe221a7bd9 test(compare): trace scenario action effects 2026-07-30 18:38:35 +00:00
Hide_D 8cdc9c9b84 compare: trace nation betting user lifecycle 2026-07-27 11:08:24 +00:00
Hide_D df189f4408 compare: trace NPC nation support lifecycle 2026-07-27 11:02:08 +00:00
Hide_D 47fa7885c5 compare: trace invader lifecycle across months 2026-07-27 10:48:40 +00:00
Hide_D 004dbb39b4 compare: add staged tax rate fixtures 2026-07-27 10:07:34 +00:00
Hide_D cd94c15137 compare: configure scenario static events 2026-07-27 09:49:51 +00:00
Hide_D eeb965353e compare: trace scenario 911 troop join event 2026-07-27 09:36:48 +00:00
Hide_D 6dd09bd4b4 compare: project general ownership presence 2026-07-27 09:09:40 +00:00
Hide_D a73189a604 compare: trace monthly disaster snapshots 2026-07-27 00:34:17 +00:00
Hide_D 4341341a07 compare: run optional general turn lifecycle 2026-07-26 23:30:25 +00:00
Hide_D 4b82f0b35c compare: preserve ordered conflict fixtures 2026-07-26 23:09:26 +00:00
Hide_D b417e117ad compare: expose active action inheritance points 2026-07-26 21:49:35 +00:00
Hide_D 85e435e99d compare: expose disband membership fields 2026-07-26 20:12:13 +00:00
Hide_D e48c13a92f compare: expose founding cooldown field 2026-07-26 17:26:57 +00:00
Hide_D 4495f0c06e compare: expose status transition fields 2026-07-26 17:03:53 +00:00
Hide_D 312711b7ed compare: expose officer city in turn snapshots 2026-07-26 15:14:49 +00:00
Hide_D 3e8c18b8d3 compare: expose canonical history logs 2026-07-26 13:09:37 +00:00
Hide_D fb0f768f2c compare: expose world killturn in turn snapshots 2026-07-26 12:54:05 +00:00
Hide_D 5fc230d89b compare: align isolated rank nation fixtures 2026-07-26 12:51:10 +00:00
Hide_D 5fd1a6c752 compare: require matching args for multistep completion 2026-07-26 12:34:49 +00:00
Hide_D 76a5ea273a compare: observe nation history in turn snapshots 2026-07-26 12:04:55 +00:00
Hide_D 3d0a5a4be8 merge: observe betrayal in turn traces 2026-07-26 10:25:49 +00:00
Hide_D 23309113ec test: observe betrayal in turn traces 2026-07-26 10:25:41 +00:00
Hide_D 1a1bc541f5 merge: clone fixture general rank data 2026-07-26 09:46:12 +00:00
Hide_D 20060a89fd test: clone fixture general rank data 2026-07-26 09:45:57 +00:00
Hide_D 24f788d1a0 merge: observe selected diplomacy pairs 2026-07-26 09:30:30 +00:00
Hide_D 813957479d test: observe selected diplomacy pairs 2026-07-26 09:30:21 +00:00
Hide_D cdf58c54e0 merge: create missing fixture generals 2026-07-26 08:52:43 +00:00
Hide_D 6399134380 test: create missing fixture generals 2026-07-26 08:52:26 +00:00
Hide_D eb49e6f8ff merge: expose nation command cooldowns 2026-07-26 08:49:50 +00:00
Hide_D ef855eb2cc test: expose nation command cooldowns 2026-07-26 08:49:41 +00:00
Hide_D c970e6586d test: expose random capital city metadata 2026-07-26 07:58:49 +00:00
Hide_D 6eaa529048 merge: compare best-general rank data 2026-07-26 06:05:12 +00:00
Hide_D fe39241a90 test: compare general rank data state 2026-07-26 06:05:06 +00:00
Hide_D d936d93be8 merge: trace general command cooldown state 2026-07-26 04:55:23 +00:00
Hide_D 89127c0258 Trace general command cooldown state 2026-07-26 04:55:17 +00:00
Hide_D 57aa07453e merge: trace instant diplomacy responses 2026-07-26 03:11:09 +00:00
Hide_D 0722f6ab5e Add instant diplomacy response traces 2026-07-26 03:10:50 +00:00
Hide_D b9c51da782 merge: observe generated nation command generals 2026-07-26 02:29:03 +00:00
Hide_D c290404e42 test: observe generated nation command generals 2026-07-26 02:28:48 +00:00
Hide_D cc0f965cb4 merge: project nation strategy limit 2026-07-26 01:55:01 +00:00
Hide_D 64736d194e test: project nation strategy limit 2026-07-26 01:54:32 +00:00
Hide_D 963b04e8b0 merge: project nation receive-assist state 2026-07-26 01:44:38 +00:00
Hide_D e58ffab2a2 test: project nation receive-assist state 2026-07-26 01:44:22 +00:00
Hide_D de29e2e157 merge: support multi-turn nation command traces 2026-07-26 01:28:27 +00:00
Hide_D 7aa1b1889c test: support multi-turn nation command traces 2026-07-26 01:28:15 +00:00
Hide_D 618cfe5720 Merge general command comparison fixtures 2026-07-26 01:05:44 +00:00
Hide_D a8cecf8ec2 test: extend general command comparison fixtures 2026-07-26 01:05:30 +00:00
Hide_D d858c64a3c merge: expand turn command fixture controls 2026-07-26 00:11:00 +00:00
Hide_D f49b1d26a5 test: expand turn command fixture controls 2026-07-26 00:10:47 +00:00
Hide_D 92e5f2472d merge: add aggregate monthly boundary trace 2026-07-25 23:43:09 +00:00
Hide_D 11144f1f88 test: add aggregate monthly boundary trace 2026-07-25 23:43:03 +00:00
Hide_D 34c9a9b53e merge: trace core monthly event actions 2026-07-25 22:47:42 +00:00
Hide_D 84d9cf9eb4 test(compare): trace core monthly event actions 2026-07-25 22:47:35 +00:00
Hide_D 8a4cc5df21 merge: trace monthly post tail effects 2026-07-25 22:33:50 +00:00
Hide_D df0b8ccebe test(compare): trace monthly post tail effects 2026-07-25 22:33:41 +00:00
Hide_D ac47bc58bd merge: add monthly wander comparison trace 2026-07-25 22:21:01 +00:00
Hide_D 570ef6f005 test(compare): trace monthly wander disband 2026-07-25 22:20:55 +00:00
Hide_D 7eecde62fa merge: add monthly diplomacy comparison trace 2026-07-25 22:08:19 +00:00
Hide_D 4e0e5c9cd7 test(compare): trace monthly diplomacy transitions 2026-07-25 22:08:08 +00:00
Hide_D 18abcde0d5 merge: add monthly nation statistics trace 2026-07-25 21:57:21 +00:00
Hide_D 6176b64c28 test(compare): trace monthly nation statistics 2026-07-25 21:57:10 +00:00
Hide_D 3bae1157b6 merge: add monthly pre-update comparison trace 2026-07-25 21:36:49 +00:00
Hide_D 5a51ad5699 test(compare): trace monthly pre-update boundary 2026-07-25 21:36:26 +00:00
Hide_D 312d20269c merge: trace remaining monthly event actions 2026-07-25 21:19:49 +00:00
Hide_D 178d6f042b test: trace monthly unique and inheritance actions 2026-07-25 21:15:35 +00:00
Hide_D 3352b64d0f merge: trace monthly speciality and betrayal actions 2026-07-25 20:55:59 +00:00
Hide_D e7d86a16d5 test: trace monthly speciality and betrayal actions 2026-07-25 20:55:40 +00:00
Hide_D 820c25d8dc merge: trace monthly scout policy actions 2026-07-25 20:28:43 +00:00
Hide_D f2dcb4d4d1 test: trace monthly scout policy actions 2026-07-25 20:28:33 +00:00
Hide_D de357cfe95 merge: trace nation betting lifecycle 2026-07-25 20:16:41 +00:00
Hide_D 0f333f8c96 Add nation betting comparison fixtures 2026-07-25 20:16:24 +00:00
Hide_D 93e8adadac merge: trace city changes and NPC troop leaders 2026-07-25 19:50:02 +00:00
Hide_D 8f98626075 test(compare): trace city changes and NPC troop leaders 2026-07-25 19:47:36 +00:00
Hide_D a321eb1362 merge: trace monthly invader lifecycle 2026-07-25 19:20:19 +00:00
Hide_D 1a17fad0a5 test: trace monthly invader lifecycle 2026-07-25 19:19:56 +00:00
Hide_D c74efbb372 merge: trace RaiseNPCNation monthly action 2026-07-25 18:56:46 +00:00
Hide_D 66f79ca5d2 test: trace monthly NPC nation creation 2026-07-25 18:56:40 +00:00
Hide_D f42f844f17 merge: trace monthly NPC registration 2026-07-25 18:40:21 +00:00
Hide_D aee98e15d7 test: trace monthly NPC registration 2026-07-25 18:40:12 +00:00
Hide_D 0936dabc9e merge: add CreateManyNPC comparison fixture 2026-07-25 18:16:11 +00:00
Hide_D 39b209caf0 test(compare): trace CreateManyNPC monthly action 2026-07-25 18:15:27 +00:00
Hide_D 07867b3aaf merge: add CreateAdminNPC no-op comparison fixture 2026-07-25 17:50:27 +00:00
Hide_D de1f74cb44 Add CreateAdminNPC no-op comparison fixture 2026-07-25 17:50:21 +00:00
Hide_D 65c97107c6 merge: add monthly war income comparison fixture 2026-07-25 17:45:43 +00:00
Hide_D d796eda13c Add monthly war income comparison fixture 2026-07-25 17:45:38 +00:00
Hide_D 2a9029470f merge: add semi-annual monthly comparison fixture 2026-07-25 17:35:40 +00:00
Hide_D 254364bab8 Add semi-annual monthly comparison fixture 2026-07-25 17:35:30 +00:00
Hide_D 0319410ade merge: add nation level monthly comparison fixture 2026-07-25 17:20:17 +00:00
Hide_D 6b64f6f8ed Add nation level monthly comparison fixture 2026-07-25 17:20:09 +00:00
Hide_D 62fcb2e987 merge: add monthly event comparison trace 2026-07-25 16:56:34 +00:00
Hide_D ff3ef4e4f3 test: add monthly city supply trace 2026-07-25 16:56:26 +00:00
Hide_D 008b0e416c merge: add full turn command comparison runner 2026-07-25 14:17:50 +00:00
Hide_D bf96849027 test: execute legacy turn command fixtures 2026-07-25 14:17:25 +00:00
Hide_D 1b035e4a42 merge: add canonical turn fixture setup 2026-07-25 11:20:57 +00:00
Hide_D 47dec60c2b test: support canonical legacy turn fixtures 2026-07-25 11:20:53 +00:00
Hide_D c03a28be93 merge: add legacy turn command trace runner 2026-07-25 11:14:28 +00:00
Hide_D d1ab2b0450 test: trace legacy turn command execution 2026-07-25 11:14:19 +00:00
Hide_D fe9ae97821 merge: add turn state differential exporter 2026-07-25 09:35:22 +00:00
Hide_D 98e0a676cb test: export canonical legacy turn snapshots 2026-07-25 09:35:04 +00:00
Hide_D 2bb1387bc4 test: expose legacy trait catalog 2026-07-25 08:05:38 +00:00
Hide_D af382c541e test: expose legacy item catalog for comparison 2026-07-25 06:38:54 +00:00
Hide_D 8616a094e5 test: add deterministic battle trace exporter 2026-07-25 05:56:45 +00:00
117 changed files with 11813 additions and 427 deletions
+26 -1
View File
@@ -42,7 +42,32 @@ sudo -u www-data git clone https://storage.hided.net/gitea/devsam/core.git
sudo -u www-data git clone https://storage.hided.net/gitea/devsam/image.git sudo -u www-data git clone https://storage.hided.net/gitea/devsam/image.git
``` ```
> 이미지는 hook/git_hook.php을 통해 동기화되며, 서버 설치 과정에 이미지 갱신 키를 지정하는 것으로 '훼' 서버 업데이트 시 동기화됩니다. 이미지 서버가 게임 서버와 별개여도 동작하나, php와 git을 지원해야합니다. > 이미지는 Gitea webhook을 기본으로 동기화합니다. Webhook 전달이 누락된 경우에도 서버 설치 과정에 이미지 갱신 API와 `core` 전용 비밀값을 지정하면 게임 서버 업데이트 명령이 서명된 동기화를 요청합니다. 이미지 서버의 브랜치 변경 권한은 부여되지 않습니다.
CLI에서 수동으로 복구 동기화를 요청할 수도 있습니다.
```sh
IMAGE_SYNC_SECRET_FILE=/run/secrets/image_sync_core_secret \
php scripts/sync-image-repository.php
```
Ref의 사용자 아이콘 원격 업로드 구현은 기본적으로 꺼져 있습니다. 이미지 서버의
`image_upload_core_secret`과 동일한 값을 Git 제외 파일
`d_setting/image_upload_core_secret`에 저장한 뒤 실제
`d_setting/ServConfig.php`에서 다음 값만 변경하면 기존 화면을 그대로 둔 채
원격 bind 저장소로 전환됩니다.
```php
public static $remoteUserIconUploadEnabled = true;
public static $remoteUserIconUploadPath = 'https://sam-image.hided.net';
public static $remoteUserIconUploadSecretFile = 'd_setting/image_upload_core_secret';
```
플래그가 `false`이면 기존 `d_pic``IMGSVR=1` 동작을 유지합니다. `true`이면
PHP 서버가 인증 사용자와 이미지 규격을 먼저 검사한 후 60초 HMAC 권한으로
이미지 서버에 직접 업로드하고 `IMGSVR=0` 공유 이미지 경로를 저장합니다.
국방·외교 등 TipTap 편집기 첨부 이미지도 같은 플래그로 `/uploads/core/` bind
저장소로 전환됩니다. 공유 비밀값은 브라우저나 Cloudflare로 보내지 않습니다.
### 설치 ### 설치
+10 -3
View File
@@ -86,21 +86,28 @@ require(__DIR__ . '/../vendor/autoload.php');
<div class="form-group row"> <div class="form-group row">
<label for="shared_icon_path" class="col-sm-4 col-form-label">공용 아이콘 주소</label> <label for="shared_icon_path" class="col-sm-4 col-form-label">공용 아이콘 주소</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input type="text" class="form-control" name="shared_icon_path" id="shared_icon_path" placeholder="공용 아이콘 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="../image/icons" /> <input type="text" class="form-control" name="shared_icon_path" id="shared_icon_path" placeholder="공용 아이콘 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="https://sam-image.hided.net/icons" />
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label for="game_image_path" class="col-sm-4 col-form-label">게임 이미지 주소</label> <label for="game_image_path" class="col-sm-4 col-form-label">게임 이미지 주소</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input type="text" class="form-control" name="game_image_path" id="game_image_path" placeholder="게임 이미지 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="../image/game" /> <input type="text" class="form-control" name="game_image_path" id="game_image_path" placeholder="게임 이미지 주소(웹 주소, 또는 접속 경로에 따른 상대 주소)" value="https://sam-image.hided.net/game" />
</div>
</div>
<div class="form-group row">
<label for="image_request_path" class="col-sm-4 col-form-label">이미지 갱신 API</label>
<div class="col-sm-8">
<input type="url" class="form-control" name="image_request_path" id="image_request_path" value="https://sam-image.hided.net/v1/sync" required />
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label for="image_request_key" class="col-sm-4 col-form-label">이미지 갱신 키</label> <label for="image_request_key" class="col-sm-4 col-form-label">이미지 갱신 키</label>
<div class="input-group col-sm-8"> <div class="input-group col-sm-8">
<input type="text" class="form-control" name="image_request_key" id="image_request_key" placeholder="이미지 서버의 hook/HashKey.php의 값과 동일하게" value="" /> <input type="text" class="form-control" name="image_request_key" id="image_request_key" placeholder="이미지 서버의 core 동기화 비밀값과 동일하게" value="" />
<div class="input-group-text"> <div class="input-group-text">
<button id="btn_random_generate_key" class="btn btn-secondary" type="button">랜덤 생성</button> <button id="btn_random_generate_key" class="btn btn-secondary" type="button">랜덤 생성</button>
</div> </div>
+21 -7
View File
@@ -12,13 +12,14 @@ $dbName = Util::getPost('db_name');
$servHost = Util::getPost('serv_host'); $servHost = Util::getPost('serv_host');
$sharedIconPath = Util::getPost('shared_icon_path'); $sharedIconPath = Util::getPost('shared_icon_path');
$gameImagePath = Util::getPost('game_image_path'); $gameImagePath = Util::getPost('game_image_path');
$imageRequestPath = Util::getPost('image_request_path');
$imageRequestKey = Util::getPost('image_request_key'); $imageRequestKey = Util::getPost('image_request_key');
$kakaoRESTKey = Util::getPost('kakao_rest_key', 'string', ''); $kakaoRESTKey = Util::getPost('kakao_rest_key', 'string', '');
$kakaoAdminKey = Util::getPost('kakao_admin_key', 'string', ''); $kakaoAdminKey = Util::getPost('kakao_admin_key', 'string', '');
if (!$host || !$port || !$username || !$password || !$dbName || !$servHost || !$sharedIconPath || !$gameImagePath) { if (!$host || !$port || !$username || !$password || !$dbName || !$servHost || !$sharedIconPath || !$gameImagePath || !$imageRequestPath) {
Json::die([ Json::die([
'result' => false, 'result' => false,
'reason' => '입력 값이 올바르지 않습니다' 'reason' => '입력 값이 올바르지 않습니다'
@@ -32,6 +33,21 @@ if (!filter_var($servHost, FILTER_VALIDATE_URL)) {
]); ]);
} }
if (!filter_var($imageRequestPath, FILTER_VALIDATE_URL)
|| parse_url($imageRequestPath, PHP_URL_SCHEME) !== 'https') {
Json::die([
'result' => false,
'reason' => '이미지 갱신 API는 HTTPS URL이어야 합니다.'
]);
}
if ($imageRequestKey !== null && $imageRequestKey !== '' && strlen($imageRequestKey) < 32) {
Json::die([
'result' => false,
'reason' => '이미지 동기화 비밀값은 32자 이상이어야 합니다.'
]);
}
if (file_exists(ROOT . '/d_setting/RootDB.php') && is_dir(ROOT . '/d_setting/RootDB.php')) { if (file_exists(ROOT . '/d_setting/RootDB.php') && is_dir(ROOT . '/d_setting/RootDB.php')) {
Json::die([ Json::die([
'result' => false, 'result' => false,
@@ -185,8 +201,7 @@ $globalSalt = bin2hex(random_bytes(16));
$sharedIconPath = WebUtil::resolveRelativePath($sharedIconPath, $servHost); $sharedIconPath = WebUtil::resolveRelativePath($sharedIconPath, $servHost);
$gameImagePath = WebUtil::resolveRelativePath($gameImagePath, $servHost); $gameImagePath = WebUtil::resolveRelativePath($gameImagePath, $servHost);
$imageRequestPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/git_pull.php', $servHost); $imageRequestPath = WebUtil::resolveRelativePath($imageRequestPath, $servHost);
$imageKeyInstallPath = WebUtil::resolveRelativePath($gameImagePath . '/../hook/InstallKey.php', $servHost);
$result = Util::generateFileUsingSimpleTemplate( $result = Util::generateFileUsingSimpleTemplate(
__DIR__ . '/templates/ServConfig.orig.php', __DIR__ . '/templates/ServConfig.orig.php',
@@ -197,6 +212,9 @@ $result = Util::generateFileUsingSimpleTemplate(
'gameImagePath' => $gameImagePath, 'gameImagePath' => $gameImagePath,
'imageRequestPath' => $imageRequestPath, 'imageRequestPath' => $imageRequestPath,
'imageRequestKey' => $imageRequestKey, 'imageRequestKey' => $imageRequestKey,
'remoteUserIconUploadEnabled' => 'false',
'remoteUserIconUploadPath' => 'https://sam-image.hided.net',
'remoteUserIconUploadSecretFile' => 'd_setting/image_upload_core_secret',
'serverList' => [ 'serverList' => [
['che', '체', 'white'], ['che', '체', 'white'],
['kwe', '퀘', 'yellow'], ['kwe', '퀘', 'yellow'],
@@ -209,10 +227,6 @@ $result = Util::generateFileUsingSimpleTemplate(
true true
); );
if ($imageRequestKey) {
@file_get_contents($imageKeyInstallPath . '?key=' . $imageRequestKey);
}
if ($result !== true) { if ($result !== true) {
Json::die([ Json::die([
'result' => false, 'result' => false,
+12 -2
View File
@@ -33,7 +33,12 @@ if ($servHost) {
[ [
'serverBasePath' => $servHost, 'serverBasePath' => $servHost,
'sharedIconPath' => $sharedIconPath, 'sharedIconPath' => $sharedIconPath,
'gameImagePath' => $gameImagePath 'gameImagePath' => $gameImagePath,
'imageRequestPath' => ServConfig::$imageRequestPath,
'imageRequestKey' => ServConfig::$imageRequestKey,
'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false',
'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath,
'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile
], ],
true true
); );
@@ -64,7 +69,12 @@ if ($servHost) {
[ [
'serverBasePath' => $servHost, 'serverBasePath' => $servHost,
'sharedIconPath' => $sharedIconPath, 'sharedIconPath' => $sharedIconPath,
'gameImagePath' => $gameImagePath 'gameImagePath' => $gameImagePath,
'imageRequestPath' => ServConfig::$imageRequestPath,
'imageRequestKey' => ServConfig::$imageRequestKey,
'remoteUserIconUploadEnabled' => ServConfig::$remoteUserIconUploadEnabled ? 'true' : 'false',
'remoteUserIconUploadPath' => ServConfig::$remoteUserIconUploadPath,
'remoteUserIconUploadSecretFile' => ServConfig::$remoteUserIconUploadSecretFile
], ],
true true
); );
+30 -3
View File
@@ -13,6 +13,9 @@ class ServConfig
public static $gameImagePath = "_tK_gameImagePath_"; public static $gameImagePath = "_tK_gameImagePath_";
public static $imageRequestPath = "_tK_imageRequestPath_"; public static $imageRequestPath = "_tK_imageRequestPath_";
public static $imageRequestKey = '_tK_imageRequestKey_'; public static $imageRequestKey = '_tK_imageRequestKey_';
public static $remoteUserIconUploadEnabled = _tK_remoteUserIconUploadEnabled_;
public static $remoteUserIconUploadPath = '_tK_remoteUserIconUploadPath_';
public static $remoteUserIconUploadSecretFile = '_tK_remoteUserIconUploadSecretFile_';
private static $serverList = null; private static $serverList = null;
public static function getSharedIconPath(string $filepath = ''): string public static function getSharedIconPath(string $filepath = ''): string
@@ -38,9 +41,33 @@ class ServConfig
public static function getImagePullURI(): string public static function getImagePullURI(): string
{ {
$now = time(); return static::$imageRequestPath;
$req_hash = Util::hashPassword(sprintf("%016x", $now), static::$imageRequestKey); }
return static::$imageRequestPath . "?req={$req_hash}&time={$now}";
public static function isRemoteUserIconUploadEnabled(): bool
{
return static::$remoteUserIconUploadEnabled;
}
public static function getRemoteUserIconUploadURI(string $filename): string
{
return rtrim(static::$remoteUserIconUploadPath, '/') . '/v1/uploads/user-icons/core/' . $filename;
}
public static function getRemoteUserIconUploadSecret(): string
{
$path = static::$remoteUserIconUploadSecretFile;
if ($path === '' || str_contains($path, "\0")) {
throw new \RuntimeException('Remote user icon upload secret file is not configured');
}
if ($path[0] !== '/') {
$path = ROOT . '/' . $path;
}
$secret = trim((string)file_get_contents($path));
if (strlen($secret) < 32) {
throw new \RuntimeException('Remote user icon upload secret must be at least 32 characters');
}
return $secret;
} }
/** /**
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace sammo;
use sammo\Enums\RankColumn;
/**
* Build the existing battle simulator request from live, pre-battle objects.
*
* This file is loaded only by the deterministic CLI comparison path. It must
* remain read-only: callers emit the returned value without changing the live
* objects or consuming RNG.
*/
function comparisonBuildBattleGeneralFixture(General $general): array
{
$raw = $general->getRaw(true);
$inheritBuff = $general->getAuxVar('inheritBuff');
$fixture = [
'no' => $general->getID(),
'name' => $general->getName(),
'nation' => $general->getNationID(),
'city' => $general->getCityID(),
'turntime' => $general->getTurnTime(General::TURNTIME_FULL_MS),
'turntick' => $general->getTurnTick(),
'personal' => $raw['personal'] ?? null,
'special' => $raw['special'] ?? null,
'special2' => $raw['special2'] ?? null,
'crew' => $raw['crew'],
'crewtype' => $raw['crewtype'],
'atmos' => $raw['atmos'],
'train' => $raw['train'],
'intel' => $raw['intel'],
'intel_exp' => $raw['intel_exp'],
'book' => $raw['book'] ?? 'None',
'strength' => $raw['strength'],
'strength_exp' => $raw['strength_exp'],
'weapon' => $raw['weapon'] ?? 'None',
'injury' => $raw['injury'],
'leadership' => $raw['leadership'],
'leadership_exp' => $raw['leadership_exp'],
'horse' => $raw['horse'] ?? 'None',
'item' => $raw['item'] ?? 'None',
'explevel' => $raw['explevel'],
'experience' => $raw['experience'],
'dedication' => $raw['dedication'],
'officer_level' => $raw['officer_level'],
'officer_city' => $raw['officer_city'],
'gold' => $raw['gold'],
'rice' => $raw['rice'],
'dex1' => $raw['dex1'],
'dex2' => $raw['dex2'],
'dex3' => $raw['dex3'],
'dex4' => $raw['dex4'],
'dex5' => $raw['dex5'],
'defence_train' => $raw['defence_train'],
'recent_war' => $raw['recent_war'] ?? null,
'warnum' => $general->getRankVar(RankColumn::warnum, 0),
'killnum' => $general->getRankVar(RankColumn::killnum, 0),
'killcrew' => $general->getRankVar(RankColumn::killcrew, 0),
];
if ($inheritBuff !== null) {
$fixture['inheritBuff'] = $inheritBuff;
}
return $fixture;
}
function comparisonBuildBattleFixture(
string $seed,
int $year,
int $month,
int $startYear,
General $attacker,
array $attackerNation,
array $defenders,
array $defenderCity,
array $defenderNation,
): array {
return [
'action' => 'battle',
'seed' => $seed,
'repeatCnt' => 1,
'year' => $year,
'month' => $month,
'startYear' => $startYear,
'scenarioEffect' => GameConst::$scenarioEffect,
'attackerGeneral' => comparisonBuildBattleGeneralFixture($attacker),
'attackerCity' => $attacker->getRawCity(),
'attackerNation' => $attackerNation,
// General::getGeneralList() can retain IDs as array keys. JSON would
// then encode the collection as an object, while both simulators use
// an ordered list. Keep the legacy iteration order but discard keys.
'defenderGenerals' => array_values(array_map(
static fn(General $general): array => comparisonBuildBattleGeneralFixture($general),
$defenders,
)),
'defenderCity' => $defenderCity,
'defenderNation' => $defenderNation,
];
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli' || getenv('REF_DETERMINISTIC_INSTALL_ENABLED') !== '1') {
http_response_code(404);
exit(1);
}
chdir(dirname(__DIR__));
require_once 'lib.php';
$rows = DB::db()->query(
'SELECT general_id, log_type, year, month, text
FROM general_record
WHERE log_type IN ("battle_brief", "battle")
ORDER BY id'
);
echo json_encode([
'schemaVersion' => 1,
'engine' => 'ref',
'logs' => array_map(static fn(array $row): array => [
'generalId' => (int)$row['general_id'],
'category' => $row['log_type'] === 'battle_brief' ? 'BATTLE_BRIEF' : 'BATTLE_DETAIL',
'year' => (int)$row['year'],
'month' => (int)$row['month'],
'text' => (string)$row['text'],
], $rows),
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), PHP_EOL;
+344
View File
@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
namespace sammo;
use Ds\Map;
use sammo\Enums\RankColumn;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
require_once 'lib.php';
require_once 'func.php';
final class ComparisonTracingRNG implements RNG
{
private int $sequence = 0;
/** @var array<int, array<string, mixed>> */
public array $calls = [];
public function __construct(private readonly RNG $inner)
{
}
public static function getMaxInt(): int
{
return LiteHashDRBG::getMaxInt();
}
public function nextBytes(int $bytes): string
{
$value = $this->inner->nextBytes($bytes);
$this->record('nextBytes', ['bytes' => $bytes], bin2hex($value));
return $value;
}
public function nextBits(int $bits): string
{
$value = $this->inner->nextBits($bits);
$this->record('nextBits', ['bits' => $bits], bin2hex($value));
return $value;
}
public function nextInt(?int $max = null): int
{
$value = $this->inner->nextInt($max);
$this->record('nextInt', ['maxInclusive' => $max], $value);
return $value;
}
public function nextFloat1(): float
{
$value = $this->inner->nextFloat1();
$this->record('nextFloat1', [], $value);
return $value;
}
private function record(string $operation, array $arguments, mixed $result): void
{
$this->calls[] = [
'seq' => $this->sequence++,
'operation' => $operation,
'arguments' => $arguments,
'result' => $result,
];
}
}
final class ComparisonTracingRandUtil extends RandUtil
{
/** @var array<int, array{rngSeq: int, probability: int|float, result: bool}> */
public array $boolCalls = [];
public function __construct(private readonly ComparisonTracingRNG $tracingRng)
{
parent::__construct($tracingRng);
}
public function nextBool(int|float $prob = 0.5): bool
{
$rngSeq = count($this->tracingRng->calls);
$result = parent::nextBool($prob);
$this->boolCalls[] = [
'rngSeq' => $rngSeq,
'probability' => $prob,
'result' => $result,
];
return $result;
}
}
final class ComparisonGeneral extends General
{
public function getTurnTime(int $short = self::TURNTIME_FULL_MS): ?string
{
$formatted = (string)($this->getRaw()['turntime'] ?? '1970-01-01 00:00:00');
return match ($short) {
self::TURNTIME_FULL_MS => $formatted,
self::TURNTIME_FULL => substr($formatted, 0, 19),
self::TURNTIME_HMS => substr($formatted, 11, 8),
self::TURNTIME_HM => substr($formatted, 11, 5),
};
}
public function getTurnTick(): ?int
{
return (int)($this->getRaw()['turntick'] ?? 0);
}
}
function comparisonExtractRankVar(array $raw): Map
{
$rankVars = new Map();
foreach ($raw as $rawKey => $rawValue) {
$key = RankColumn::tryFrom($rawKey);
if ($key !== null) {
$rankVars[$key] = $rawValue;
}
}
return $rankVars;
}
function comparisonBuildGeneral(
array $raw,
array $city,
array $nation,
int $year,
int $month,
bool $defender = false,
): General {
$aux = [];
if (array_key_exists('inheritBuff', $raw)) {
$aux['inheritBuff'] = $raw['inheritBuff'];
}
$raw['aux'] = Json::encode($aux);
$raw['owner'] = 0;
return new ComparisonGeneral(
$raw,
comparisonExtractRankVar($raw),
null,
$city,
$nation,
$year,
$month,
$defender,
);
}
/**
* Some legacy battle items read game time through KVStorage instead of the
* General/WarUnit time arguments. Seed only the process-local cache so each
* comparison fixture observes its own clock without mutating the shared Ref DB.
*/
function comparisonOverrideGameTime(int $year, int $month, int $startYear): void
{
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
$cacheProperty = new \ReflectionProperty(KVStorage::class, 'cacheData');
$cacheProperty->setAccessible(true);
$cache = $cacheProperty->getValue($gameStorage);
if (!($cache instanceof Map)) {
$cache = new Map();
}
$cache['year'] = $year;
$cache['month'] = $month;
$cache['startyear'] = $startYear;
$cacheProperty->setValue($gameStorage, $cache);
}
function comparisonRunBattle(array $fixture, ?string $fixtureJson = null): array
{
$seed = (string)($fixture['seed'] ?? 'battle-differential');
$identityJson = trim($fixtureJson ?? json_encode(
$fixture,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
));
$year = (int)$fixture['year'];
$month = (int)$fixture['month'];
$startYear = (int)($fixture['startYear'] ?? 180);
comparisonOverrideGameTime($year, $month, $startYear);
$rawAttacker = $fixture['attackerGeneral'];
$rawAttackerCity = $fixture['attackerCity'];
$rawAttackerNation = $fixture['attackerNation'];
$rawDefenderCity = $fixture['defenderCity'];
$rawDefenderNation = $fixture['defenderNation'];
// 비교 fixture에 명시된 경우에만 ref runtime의 scenario action chain을
// 활성화합니다. 일반 비교 fixture의 기존 동작은 그대로 유지합니다.
$scenarioEffect = $fixture['scenarioEffect'] ?? null;
if ($scenarioEffect === '' || $scenarioEffect === 'None') {
$scenarioEffect = null;
}
if (
$scenarioEffect !== null
&& (!is_string($scenarioEffect) || getScenarioEffectClass($scenarioEffect) === null)
) {
throw new \InvalidArgumentException('scenarioEffect is invalid');
}
GameConst::$scenarioEffect = $scenarioEffect;
$tracingRng = new ComparisonTracingRNG(new LiteHashDRBG($seed));
$warRng = new ComparisonTracingRandUtil($tracingRng);
$attacker = new WarUnitGeneral(
$warRng,
comparisonBuildGeneral($rawAttacker, $rawAttackerCity, $rawAttackerNation, $year, $month),
$rawAttackerNation,
true,
);
$city = new WarUnitCity($warRng, $rawDefenderCity, $rawDefenderNation, $year, $month, $startYear);
$defenderList = [];
foreach ($fixture['defenderGenerals'] as $rawDefender) {
$defenderList[] = new WarUnitGeneral(
$warRng,
comparisonBuildGeneral($rawDefender, $rawDefenderCity, $rawDefenderNation, $year, $month, true),
$rawDefenderNation,
false,
);
}
if (count($defenderList) && extractBattleOrder($city, $attacker) > 0) {
$defenderList[] = $city;
}
$summarizeDefenderOrder = static fn(WarUnit $unit): array => [
'id' => $unit instanceof WarUnitGeneral ? $unit->getGeneral()->getID() : 0,
'order' => extractBattleOrder($unit, $attacker),
];
$defenderOrderBeforeSort = array_map($summarizeDefenderOrder, $defenderList);
usort(
$defenderList,
fn(WarUnit $lhs, WarUnit $rhs): int =>
-(extractBattleOrder($lhs, $attacker) <=> extractBattleOrder($rhs, $attacker)),
);
$defenderOrderAfterSort = array_map($summarizeDefenderOrder, $defenderList);
$iterDefender = new \ArrayIterator($defenderList);
$iterDefender->rewind();
$finishedDefenders = [];
$getNextDefender = function (?WarUnit $previous, bool $requestNext) use (
$iterDefender,
$attacker,
&$finishedDefenders,
): ?WarUnit {
if ($previous !== null) {
$finishedDefenders[] = buildWarTraceUnitSnapshot($previous);
}
if (!$requestNext || !$iterDefender->valid()) {
return null;
}
$next = $iterDefender->current();
if (extractBattleOrder($next, $attacker) <= 0) {
return null;
}
$iterDefender->next();
return $next;
};
$events = [];
$conquered = processWar_NG(
$seed,
$attacker,
$getNextDefender,
$city,
static function (array $event) use (&$events): void {
$events[] = $event;
},
);
$attackerLogs = $attacker->getLogger()->rollback();
$defenderLogs = [];
foreach ($defenderList as $defenderUnit) {
if (!($defenderUnit instanceof WarUnitGeneral)) {
continue;
}
$snapshot = buildWarTraceUnitSnapshot($defenderUnit);
$defenderLogs[(string)$snapshot['id']] = $defenderUnit->getLogger()->rollback();
}
$cityLogs = $city->getLogger()->rollback();
return [
'engine' => 'ref',
'seed' => $seed,
'fixtureIdentity' => [
'schemaVersion' => 1,
'seed' => $seed,
'sha256' => hash('sha256', $identityJson),
],
'conquered' => $conquered,
'attacker' => buildWarTraceUnitSnapshot($attacker),
'city' => buildWarTraceUnitSnapshot($city),
'finishedDefenders' => $finishedDefenders,
'defenderOrder' => [
'before' => $defenderOrderBeforeSort,
'after' => $defenderOrderAfterSort,
],
'events' => $events,
'rng' => $tracingRng->calls,
'boolRng' => $warRng->boolCalls,
'logs' => [
'attacker' => $attackerLogs,
'defenders' => $defenderLogs,
'city' => $cityLogs,
],
];
}
$fixturePath = $argv[1] ?? null;
if ($fixturePath === '--jsonl') {
try {
while (($line = fgets(STDIN)) !== false) {
$line = trim($line);
if ($line === '') {
continue;
}
$fixture = json_decode($line, true, flags: JSON_THROW_ON_ERROR);
echo json_encode(
comparisonRunBattle($fixture, $line),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
}
exit(0);
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
exit(1);
}
}
if ($fixturePath === null || ($fixturePath !== '-' && !is_file($fixturePath))) {
fwrite(STDERR, "usage: php compare/battle_trace.php <fixture.json|-|--jsonl>\n");
exit(2);
}
try {
$fixtureJson = $fixturePath === '-' ? stream_get_contents(STDIN) : file_get_contents($fixturePath);
$fixture = json_decode((string)$fixtureJson, true, flags: JSON_THROW_ON_ERROR);
echo json_encode(
comparisonRunBattle($fixture, (string)$fixtureJson),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
exit(1);
}
@@ -0,0 +1,23 @@
{
"action": "AddGlobalBetray",
"args": [2, 1],
"setup": {
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2, 3],
"general": [
{ "id": 1, "values": { "name": "배신0", "betray": 0 } },
{ "id": 2, "values": { "name": "배신1", "betray": 1 } },
{ "id": 3, "values": { "name": "배신2", "betray": 2 } }
]
},
"environment": {
"year": 194,
"month": 1,
"startyear": 190
},
"observe": {
"generalIds": [1, 2, 3]
},
"compactOutput": true
}
@@ -0,0 +1,79 @@
{
"action": "AssignGeneralSpeciality",
"setup": {
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2, 3],
"general": [
{
"id": 1,
"values": {
"name": "내정대상",
"nation": 1,
"age": 30,
"leadership": 40,
"strength": 45,
"intel": 80,
"special": "None",
"specage": 30,
"special2": "che_신산",
"specage2": 99,
"aux": {
"prev_types_special": ["che_경작"]
}
}
},
{
"id": 2,
"values": {
"name": "전투대상",
"nation": 1,
"age": 30,
"leadership": 80,
"strength": 75,
"intel": 40,
"special": "che_인덕",
"specage": 99,
"special2": "None",
"specage2": 30,
"dex1": 200,
"dex2": 10,
"dex3": 10,
"dex4": 10,
"dex5": 10,
"aux": {
"prev_types_special2": ["che_돌격"]
}
}
},
{
"id": 3,
"values": {
"name": "계승대상",
"nation": 2,
"age": 30,
"leadership": 50,
"strength": 50,
"intel": 50,
"special": "che_경작",
"specage": 99,
"special2": "None",
"specage2": 30,
"aux": {
"inheritSpecificSpecialWar": "che_의술",
"marker": 3
}
}
}
]
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190
},
"observe": {
"generalIds": [1, 2, 3]
},
"compactOutput": true
}
@@ -0,0 +1,54 @@
{
"action": "AutoDeleteInvader",
"compactOutput": true,
"args": [1],
"setup": {
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"resetDiplomacy": true,
"nation": [
{
"id": 1,
"values": {
"name": "ⓞ남만족",
"capital": 69,
"gennum": 1,
"tech": 100,
"level": 2,
"type": "che_병가"
}
}
],
"general": [
{
"id": 1,
"values": {
"name": "ⓞ남만대왕",
"nation": 1,
"city": 69,
"officer_level": 12,
"npc": 9
}
}
],
"event": [
{
"id": 990001,
"action": [["AutoDeleteInvader", 1]]
}
]
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"currentEventID": 990001
},
"observe": {
"generalIds": [1]
}
}
@@ -0,0 +1,24 @@
{
"action": "BlockScoutAction",
"args": [true],
"setup": {
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"nation": [
{ "id": 1, "values": { "name": "위", "level": 2, "scout": 0 } },
{ "id": 2, "values": { "name": "촉", "level": 2, "scout": 0 } }
],
"gameEnvironment": {
"block_change_scout": false
}
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190
},
"observe": {
"nationIds": [1, 2]
}
}
@@ -0,0 +1,226 @@
{
"action": "MonthlyBoundary",
"compactOutput": true,
"environment": {
"year": 190,
"month": 12,
"startyear": 190,
"turnterm": 10,
"starttime": "2026-07-25 00:00:00",
"turntime": "2026-07-25 02:00:00"
},
"setup": {
"syncEnvironment": true,
"world": {
"hiddenSeed": "monthly-boundary-core-catalog"
},
"gameEnvironment": {
"isunited": 1,
"refreshLimit": 3,
"block_change_scout": false
},
"resetCities": true,
"deleteOtherCities": true,
"keepCityIds": [1, 2],
"city": [
{
"id": 1,
"values": {
"name": "경계도시",
"level": 1,
"nation": 1,
"pop": 10000,
"pop_max": 20000,
"agri": 1000,
"agri_max": 2000,
"comm": 1000,
"comm_max": 2000,
"secu": 1000,
"secu_max": 2000,
"trust": 50,
"trade": 100,
"def": 1000,
"def_max": 2000,
"wall": 1000,
"wall_max": 2000,
"officer_set": 1,
"state": 31,
"term": 1,
"supply": 1,
"front": 2,
"conflict": {},
"dead": 0
}
},
{
"id": 2,
"values": {
"name": "중립도시",
"level": 8,
"nation": 0,
"pop": 12000,
"pop_max": 22000,
"agri": 1200,
"agri_max": 2200,
"comm": 1200,
"comm_max": 2200,
"secu": 1200,
"secu_max": 2200,
"trust": 55,
"trade": 100,
"def": 1200,
"def_max": 2200,
"wall": 1200,
"wall_max": 2200,
"officer_set": 1,
"state": 0,
"term": 0,
"supply": 0,
"front": 0,
"conflict": {},
"dead": 0
}
}
],
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{
"id": 1,
"values": {
"name": "경계군주",
"nation": 1,
"city": 1,
"officer_level": 12,
"npc": 0,
"age": 30,
"belong": 3,
"makelimit": 2,
"gold": 1000,
"rice": 1000,
"crew": 100,
"train": 80,
"atmos": 70,
"leadership": 50,
"strength": 50,
"intel": 50,
"experience": 0,
"dedication": 0,
"dex1": 0,
"dex2": 0,
"dex3": 0,
"dex4": 0,
"dex5": 0,
"personal": "None",
"special": "None",
"specage": 0,
"special2": "None",
"specage2": 0,
"betray": 0,
"bornyear": 160,
"deadyear": 250
}
},
{
"id": 2,
"values": {
"name": "경계재야",
"nation": 0,
"city": 2,
"officer_level": 0,
"npc": 0,
"age": 25,
"belong": 4,
"makelimit": 0,
"gold": 500,
"rice": 500,
"crew": 50,
"train": 60,
"atmos": 50,
"leadership": 40,
"strength": 40,
"intel": 40,
"experience": 0,
"dedication": 0,
"dex1": 0,
"dex2": 0,
"dex3": 0,
"dex4": 0,
"dex5": 0,
"personal": "None",
"special": "None",
"specage": 0,
"special2": "None",
"specage2": 0,
"betray": 0,
"bornyear": 165,
"deadyear": 250
}
}
],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"nation": [
{
"id": 1,
"values": {
"name": "경계국",
"color": "#123456",
"capital": 1,
"gennum": 1,
"gold": 10000,
"rice": 20000,
"tech": 100,
"power": 10,
"level": 2,
"type": "che_유가",
"bill": 100,
"rate": 20,
"rate_tmp": 10,
"scout": 1,
"chief_set": 1,
"strategic_cmd_limit": 2,
"surlimit": 1,
"spy": "{}"
}
}
],
"resetDiplomacy": true,
"resetEvents": true,
"event": [
{
"id": 991001,
"target": "PRE_MONTH",
"priority": 1000,
"condition": true,
"action": [
["NoticeToHistoryLog", "aggregate-pre", 6]
]
},
{
"id": 991002,
"target": "MONTH",
"priority": 1000,
"condition": true,
"action": [
["ProcessIncome", "gold"],
["NewYear"],
["ResetOfficerLock"],
["RandomizeCityTradeRate"],
["ChangeCity", "all", {"pop": "+10", "trust": 60}],
["NoticeToHistoryLog", "aggregate-month", 6],
["DeleteEvent"]
]
}
]
},
"observe": {
"generalIds": [1, 2],
"nationIds": [1],
"cityIds": [1, 2],
"eventIds": [991001, 991002],
"accessGeneralIds": [1, 2]
}
}
@@ -0,0 +1,34 @@
{
"action": "NewYear",
"environment": {
"year": 191,
"month": 1,
"startyear": 190
},
"setup": {
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{
"id": 1,
"values": {
"nation": 1,
"age": 30,
"belong": 3
}
},
{
"id": 2,
"values": {
"nation": 0,
"age": 30,
"belong": 4
}
}
]
},
"observe": {
"generalIds": [1, 2]
}
}
@@ -0,0 +1,11 @@
{
"action": "NoticeToHistoryLog",
"args": ["<S>새해 알림</>", 6],
"environment": {
"year": 191,
"month": 1,
"startyear": 190
},
"setup": {},
"observe": {}
}
@@ -0,0 +1,75 @@
{
"action": "ProcessIncome",
"args": ["gold"],
"environment": {
"year": 190,
"month": 7,
"startyear": 190
},
"setup": {
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"general": [
{
"id": 1,
"values": {
"nation": 1,
"city": 1,
"officer_level": 5,
"npc": 0,
"gold": 1000,
"rice": 1000,
"dedication": 100
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "갑국",
"capital": 1,
"gold": 10000,
"rice": 20000,
"level": 1,
"rate_tmp": 20
}
}
],
"city": [
{
"id": 1,
"values": {
"name": "갑성",
"nation": 1,
"level": 4,
"supply": 1,
"pop": 10000,
"pop_max": 20000,
"agri": 1000,
"agri_max": 2000,
"comm": 1000,
"comm_max": 2000,
"secu": 1000,
"secu_max": 2000,
"trust": 80,
"def": 500,
"def_max": 1000,
"wall": 500,
"wall_max": 1000
}
}
]
},
"observe": {
"generalIds": [1],
"nationIds": [1],
"cityIds": [1],
"nationEnvironmentKeys": ["prev_income_gold", "prev_income_rice"]
}
}
@@ -0,0 +1,33 @@
{
"action": "ResetOfficerLock",
"environment": {
"year": 191,
"month": 1,
"startyear": 190
},
"setup": {
"resetCities": true,
"resetNations": true,
"city": [
{
"id": 1,
"values": {
"nation": 1,
"officer_set": 1
}
}
],
"nation": [
{
"id": 1,
"values": {
"chief_set": 1
}
}
]
},
"observe": {
"cityIds": [1],
"nationIds": [1]
}
}
@@ -0,0 +1,9 @@
{
"action": "CreateAdminNPC",
"environment": {
"year": 193,
"month": 12,
"startyear": 190
},
"observe": {}
}
@@ -0,0 +1,15 @@
{
"action": "CreateManyNPC",
"args": [2, 0],
"environment": {
"year": 193,
"month": 5,
"startyear": 190,
"turnterm": 10,
"turntime": "0193-05-01 00:00:00"
},
"setup": {
"resetGenerals": true
},
"observe": {}
}
@@ -0,0 +1,69 @@
{
"action": "FinishNationBetting",
"args": [1],
"setup": {
"clearBetting": true,
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"nation": [
{ "id": 1, "values": { "name": "위", "gennum": 1, "power": 100, "level": 2 } },
{ "id": 2, "values": { "name": "촉", "gennum": 1, "power": 300, "level": 0 } }
],
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{ "id": 1, "values": { "name": "장수1", "nation": 1, "owner": 1, "npc": 0 } },
{ "id": 2, "values": { "name": "장수2", "nation": 2, "owner": 2, "npc": 0 } }
],
"bettingInfo": [
{
"id": 1,
"type": "bettingNation",
"name": "천통국 예상",
"finished": false,
"selectCnt": 1,
"isExclusive": null,
"reqInheritancePoint": true,
"openYearMonth": 2400,
"closeYearMonth": 2424,
"candidates": [
{
"title": "위",
"info": "국력: 100<br>장수 수: 1<br>도시 수: 1",
"isHtml": true,
"aux": { "nation": 1 }
},
{
"title": "촉",
"info": "국력: 300<br>장수 수: 1<br>도시 수: 1",
"isHtml": true,
"aux": { "nation": 2 }
}
],
"winner": null
}
],
"bet": [
{ "betting_id": 1, "general_id": 0, "user_id": null, "betting_type": "[-1]", "amount": 100 },
{ "betting_id": 1, "general_id": 1, "user_id": 1, "betting_type": "[0]", "amount": 100 },
{ "betting_id": 1, "general_id": 2, "user_id": 2, "betting_type": "[1]", "amount": 100 }
],
"inheritancePrevious": {
"1": 900,
"2": 900
},
"syncEnvironment": true
},
"environment": {
"year": 202,
"month": 1,
"startyear": 190
},
"observe": {
"generalIds": [1, 2],
"nationIds": [1, 2],
"ownerIds": [1, 2]
}
}
@@ -0,0 +1,40 @@
{
"action": "InvaderEnding",
"compactOutput": true,
"setup": {
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"setAllCitiesNation": 1,
"nation": [
{
"id": 1,
"values": {
"name": "기준국",
"capital": 1,
"gennum": 1,
"tech": 100,
"level": 2,
"type": "che_유가"
}
}
],
"gameEnvironment": {
"isunited": 1,
"refreshLimit": 3
},
"event": [
{
"id": 990002,
"action": [["InvaderEnding"]]
}
]
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"currentEventID": 990002
},
"observe": {}
}
@@ -0,0 +1,108 @@
{
"action": "InvaderLifecycle",
"args": [10, 150, 100, 20],
"setup": {
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"resetDiplomacy": true,
"resetEvents": true,
"city": [
{
"id": 1,
"values": {
"name": "기준도시",
"level": 3,
"nation": 1,
"pop": 1000,
"pop_max": 10000,
"agri": 2000,
"agri_max": 20000,
"comm": 3000,
"comm_max": 30000,
"secu": 4000,
"secu_max": 40000,
"def": 5000,
"def_max": 50000,
"wall": 6000,
"wall_max": 60000
}
},
{
"id": 48,
"values": {
"name": "남만",
"level": 4,
"nation": 0,
"pop": 1000,
"pop_max": 10000,
"agri": 2000,
"agri_max": 20000,
"comm": 3000,
"comm_max": 30000,
"secu": 4000,
"secu_max": 40000,
"def": 5000,
"def_max": 50000,
"wall": 6000,
"wall_max": 60000
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "기준국",
"capital": 1,
"gennum": 1,
"tech": 100,
"level": 2,
"type": "che_유가"
}
}
],
"general": [
{
"id": 1,
"values": {
"name": "군주",
"nation": 1,
"city": 1,
"officer_level": 12,
"npc": 0,
"leadership": 50,
"strength": 50,
"intel": 50,
"experience": 1000,
"dedication": 1000,
"dex1": 10,
"dex2": 20,
"dex3": 30,
"dex4": 40,
"dex5": 50,
"turntime": "0200-01-01 00:05:00"
}
}
],
"gameEnvironment": {
"isunited": 0,
"refreshLimit": 3
},
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {}
}
@@ -0,0 +1,42 @@
{
"action": "LostUniqueItem",
"args": [0.2],
"environment": {
"year": 210,
"month": 1,
"startyear": 180
},
"setup": {
"syncEnvironment": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{
"id": 1,
"values": {
"name": "갑",
"npc": 0,
"horse": "che_명마_12_옥란백용구",
"weapon": "che_무기_15_의천검",
"book": "che_서적_01_효경전",
"item": "che_저지_삼황내문"
}
},
{
"id": 2,
"values": {
"name": "을",
"npc": 1,
"horse": "che_명마_12_사륜거",
"weapon": "che_무기_01_단도",
"book": "None",
"item": "che_부적_태현청생부"
}
}
]
},
"observe": {
"generalIds": [1, 2]
}
}
@@ -0,0 +1,74 @@
{
"action": "MergeInheritPointRank",
"environment": {
"year": 210,
"month": 1,
"startyear": 180
},
"setup": {
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"clearInheritanceOwnerIds": [100],
"gameEnvironment": {
"isunited": 0
},
"general": [
{
"id": 1,
"values": {
"name": "갑",
"nation": 1,
"owner": 100,
"npc": 0,
"belong": 3,
"dex1": 1000,
"dex2": 0,
"dex3": 0,
"dex4": 0,
"dex5": 0
}
}
],
"inheritancePoints": [
{"ownerId": 100, "key": "previous", "value": 999},
{"ownerId": 100, "key": "lived_month", "value": 2},
{"ownerId": 100, "key": "max_domestic_critical", "value": 4},
{"ownerId": 100, "key": "active_action", "value": 15},
{"ownerId": 100, "key": "unifier", "value": 6},
{"ownerId": 100, "key": "tournament", "value": 7}
],
"rankData": [
{"generalId": 1, "type": "warnum", "value": 2},
{"generalId": 1, "type": "firenum", "value": 1},
{"generalId": 1, "type": "betwin", "value": 2},
{"generalId": 1, "type": "betgold", "value": 4000},
{"generalId": 1, "type": "betwingold", "value": 2000},
{"generalId": 1, "type": "inherit_earned_dyn", "value": 999},
{"generalId": 1, "type": "inherit_earned_act", "value": 11},
{"generalId": 1, "type": "inherit_earned", "value": 999},
{"generalId": 1, "type": "inherit_spent_dyn", "value": 9},
{"generalId": 1, "type": "inherit_spent", "value": 999}
]
},
"observe": {
"generalIds": [1],
"ownerIds": [100],
"inheritanceKeys": [
"previous",
"lived_month",
"max_domestic_critical",
"active_action",
"unifier",
"tournament"
],
"rankGeneralIds": [1],
"rankTypes": [
"inherit_earned",
"inherit_earned_act",
"inherit_earned_dyn",
"inherit_spent",
"inherit_spent_dyn"
]
}
}
@@ -0,0 +1,75 @@
{
"action": "NationBettingLifecycle",
"args": [1, 100],
"bettorGeneralIds": [1, 2],
"bettorOwnerIds": [1, 2],
"setup": {
"clearBetting": true,
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"nation": [
{
"id": 1,
"values": {
"name": "위",
"color": "#111111",
"capital": 1,
"gennum": 1,
"gold": 1000,
"rice": 2000,
"tech": 100,
"power": 100,
"level": 2,
"type": "che_유가"
}
},
{
"id": 2,
"values": {
"name": "촉",
"color": "#222222",
"capital": 2,
"gennum": 1,
"gold": 3000,
"rice": 4000,
"tech": 200,
"power": 300,
"level": 2,
"type": "che_병가"
}
}
],
"resetCities": true,
"deleteOtherCities": true,
"keepCityIds": [1, 2],
"city": [
{ "id": 1, "values": { "name": "허창", "nation": 1, "level": 3 } },
{ "id": 2, "values": { "name": "성도", "nation": 2, "level": 3 } }
],
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{ "id": 1, "values": { "name": "장수1", "nation": 1, "city": 1, "owner": 1, "npc": 0 } },
{ "id": 2, "values": { "name": "장수2", "nation": 2, "city": 2, "owner": 2, "npc": 0 } }
],
"inheritancePrevious": {
"1": 1000,
"2": 1000
},
"rankData": [
{ "generalId": 1, "type": "inherit_spent_dyn", "value": 0 },
{ "generalId": 1, "type": "inherit_earned_act", "value": 0 },
{ "generalId": 2, "type": "inherit_spent_dyn", "value": 0 },
{ "generalId": 2, "type": "inherit_earned_act", "value": 0 }
],
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190
},
"observe": {}
}
@@ -0,0 +1,77 @@
{
"action": "NpcNationLifecycle",
"setup": {
"resetCities": true,
"resetGenerals": true,
"resetNations": true,
"city": [
{
"id": 1,
"values": {
"name": "점유도시",
"level": 5,
"nation": 1,
"pop": 10001,
"pop_max": 50001,
"agri": 1001,
"agri_max": 5001,
"comm": 2001,
"comm_max": 6001,
"secu": 3001,
"secu_max": 7001,
"def": 4001,
"def_max": 8001,
"wall": 5001,
"wall_max": 9001
}
},
{
"id": 48,
"values": {
"name": "건국도시",
"level": 5,
"nation": 0,
"pop": 10048,
"pop_max": 50048,
"agri": 1048,
"agri_max": 5048,
"comm": 2048,
"comm_max": 6048,
"secu": 3048,
"secu_max": 7048,
"def": 4048,
"def_max": 8048,
"wall": 5048,
"wall_max": 9048
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "기준국",
"capital": 1,
"gennum": 0,
"tech": 120,
"level": 2,
"type": "che_유가"
}
}
],
"gameEnvironment": {
"lastNPCTroopLeaderID": 40
},
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {}
}
@@ -0,0 +1,65 @@
{
"action": "OpenNationBetting",
"args": [1, 500],
"setup": {
"clearBetting": true,
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"nation": [
{
"id": 1,
"values": {
"name": "위",
"color": "#111111",
"capital": 1,
"gennum": 1,
"gold": 1000,
"rice": 2000,
"tech": 100,
"power": 100,
"level": 2,
"type": "che_유가"
}
},
{
"id": 2,
"values": {
"name": "촉",
"color": "#222222",
"capital": 2,
"gennum": 1,
"gold": 3000,
"rice": 4000,
"tech": 200,
"power": 300,
"level": 2,
"type": "che_병가"
}
}
],
"resetCities": true,
"city": [
{ "id": 1, "values": { "nation": 1, "level": 3 } },
{ "id": 2, "values": { "nation": 2, "level": 3 } }
],
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{ "id": 1, "values": { "name": "장수1", "nation": 1, "city": 1, "owner": 1, "npc": 0 } },
{ "id": 2, "values": { "name": "장수2", "nation": 2, "city": 2, "owner": 2, "npc": 0 } }
],
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190
},
"observe": {
"generalIds": [1, 2],
"cityIds": [1, 2],
"nationIds": [1, 2]
}
}
@@ -0,0 +1,178 @@
{
"action": "PostUpdateMonthly",
"environment": {
"year": 193,
"month": 2,
"startyear": 193,
"seedYear": 193,
"seedMonth": 1
},
"setup": {
"world": {
"hiddenSeed": "monthly-post-diplomacy-fixture"
},
"syncEnvironment": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2, 3, 4],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2, 3, 4],
"general": [
{
"id": 1,
"values": {
"name": "갑장",
"nation": 1,
"npc": 2,
"crew": 0,
"gold": 0,
"rice": 0,
"leadership": 10,
"strength": 10,
"intel": 10,
"experience": 0,
"dedication": 0
}
},
{
"id": 2,
"values": {
"name": "을장",
"nation": 2,
"npc": 2,
"crew": 0,
"gold": 0,
"rice": 0,
"leadership": 10,
"strength": 10,
"intel": 10,
"experience": 0,
"dedication": 0
}
},
{
"id": 3,
"values": {
"name": "병장",
"nation": 3,
"npc": 2,
"crew": 0,
"gold": 0,
"rice": 0,
"leadership": 10,
"strength": 10,
"intel": 10,
"experience": 0,
"dedication": 0
}
},
{
"id": 4,
"values": {
"name": "정장",
"nation": 4,
"npc": 2,
"crew": 0,
"gold": 0,
"rice": 0,
"leadership": 10,
"strength": 10,
"intel": 10,
"experience": 0,
"dedication": 0
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "갑국",
"gennum": 2,
"level": 1
}
},
{
"id": 2,
"values": {
"name": "을국",
"gennum": 1,
"level": 1
}
},
{
"id": 3,
"values": {
"name": "병국",
"gennum": 1,
"level": 1
}
},
{
"id": 4,
"values": {
"name": "정국",
"gennum": 1,
"level": 1
}
}
],
"resetDiplomacy": true,
"diplomacy": [
{
"fromNationId": 1,
"toNationId": 2,
"state": 1,
"term": 1,
"dead": 777
},
{
"fromNationId": 2,
"toNationId": 1,
"state": 1,
"term": 1,
"dead": 888
},
{
"fromNationId": 1,
"toNationId": 3,
"state": 0,
"term": 5,
"dead": 250
},
{
"fromNationId": 3,
"toNationId": 1,
"state": 0,
"term": 5,
"dead": 50
},
{
"fromNationId": 3,
"toNationId": 4,
"state": 0,
"term": 1,
"dead": 0
},
{
"fromNationId": 4,
"toNationId": 3,
"state": 0,
"term": 1,
"dead": 0
},
{
"fromNationId": 2,
"toNationId": 4,
"state": 7,
"term": 1,
"dead": 999
}
]
},
"observe": {
"generalIds": [1, 2, 3, 4],
"nationIds": [1, 2, 3, 4]
}
}
@@ -0,0 +1,162 @@
{
"action": "PostUpdateMonthly",
"environment": {
"year": 193,
"month": 2,
"startyear": 193,
"seedYear": 193,
"seedMonth": 1
},
"setup": {
"world": {
"hiddenSeed": "monthly-post-nation-stats-fixture"
},
"syncEnvironment": true,
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"resetDiplomacy": true,
"general": [
{
"id": 1,
"values": {
"nation": 1,
"npc": 0,
"gold": 1000,
"rice": 2000,
"crew": 100,
"leadership": 50,
"strength": 40,
"intel": 30,
"experience": 100,
"dedication": 200,
"dex1": 1000,
"dex2": 0,
"dex3": 0,
"dex4": 0,
"dex5": 0
}
},
{
"id": 2,
"values": {
"nation": 2,
"npc": 2,
"gold": 3000,
"rice": 4000,
"crew": 200,
"leadership": 60,
"strength": 50,
"intel": 40,
"experience": 300,
"dedication": 400,
"dex1": 2000,
"dex2": 1000,
"dex3": 0,
"dex4": 0,
"dex5": 0
}
}
],
"rankData": [
{"generalId": 1, "type": "killcrew_person", "value": 100},
{"generalId": 1, "type": "deathcrew_person", "value": 0},
{"generalId": 2, "type": "killcrew_person", "value": 0},
{"generalId": 2, "type": "deathcrew_person", "value": 100}
],
"nation": [
{
"id": 1,
"values": {
"name": "갑국",
"gold": 10000,
"rice": 20000,
"tech": 100,
"level": 2,
"gennum": 9,
"power": 7
}
},
{
"id": 2,
"values": {
"name": "을국",
"gold": 2000,
"rice": 3000,
"tech": 50,
"level": 1,
"gennum": 8,
"power": 8
}
}
],
"city": [
{
"id": 1,
"values": {
"name": "갑성",
"nation": 1,
"level": 4,
"supply": 1,
"pop": 1000,
"pop_max": 2000,
"agri": 100,
"agri_max": 200,
"comm": 100,
"comm_max": 200,
"secu": 100,
"secu_max": 200,
"def": 100,
"def_max": 200,
"wall": 100,
"wall_max": 200
}
},
{
"id": 2,
"values": {
"name": "을성",
"nation": 2,
"level": 4,
"supply": 1,
"pop": 2000,
"pop_max": 4000,
"agri": 200,
"agri_max": 400,
"comm": 200,
"comm_max": 400,
"secu": 200,
"secu_max": 400,
"def": 200,
"def_max": 400,
"wall": 200,
"wall_max": 400
}
}
],
"nationEnvironment": [
{
"nationId": 1,
"key": "max_power",
"value": {
"maxPower": 999,
"maxCrew": 50,
"maxCities": ["옛도시", "옛도시2"]
}
},
{"nationId": 1, "key": "available_war_setting_cnt", "value": 1}
]
},
"observe": {
"generalIds": [1, 2],
"nationIds": [1, 2],
"cityIds": [1, 2],
"rankGeneralIds": [1, 2],
"rankTypes": ["killcrew_person", "deathcrew_person"],
"nationEnvironmentKeys": ["max_power", "available_war_setting_cnt"]
}
}
@@ -0,0 +1,128 @@
{
"action": "PostUpdateMonthly",
"environment": {
"year": 193,
"month": 2,
"startyear": 193,
"seedYear": 193,
"seedMonth": 1
},
"setup": {
"world": {
"hiddenSeed": "monthly-post-tail-2"
},
"syncEnvironment": true,
"clearNeutralAuctions": true,
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"resetDiplomacy": true,
"gameEnvironment": {
"tournament": 0,
"tnmt_trig": true,
"tnmt_pattern": [0, 1, 2, 3],
"prev_winner": null
},
"general": [
{
"id": 1,
"values": {
"name": "갑장",
"nation": 1,
"city": 1,
"officer_level": 12,
"npc": 0,
"gold": 5000,
"rice": 7000,
"crew": 100,
"leadership": 50,
"strength": 40,
"intel": 30,
"experience": 100,
"dedication": 200
}
},
{
"id": 2,
"values": {
"name": "을장",
"nation": 2,
"city": 2,
"officer_level": 12,
"npc": 0,
"gold": 6000,
"rice": 8000,
"crew": 200,
"leadership": 60,
"strength": 50,
"intel": 40,
"experience": 300,
"dedication": 400
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "갑국",
"capital": 1,
"gold": 10000,
"rice": 20000,
"tech": 100,
"level": 2,
"gennum": 1,
"power": 7
}
},
{
"id": 2,
"values": {
"name": "을국",
"capital": 2,
"gold": 2000,
"rice": 3000,
"tech": 50,
"level": 1,
"gennum": 1,
"power": 8
}
}
],
"city": [
{
"id": 1,
"values": {
"name": "갑성",
"nation": 1,
"level": 4,
"supply": 1,
"front": 3,
"pop": 1000,
"pop_max": 2000
}
},
{
"id": 2,
"values": {
"name": "을성",
"nation": 2,
"level": 4,
"supply": 1,
"front": 3,
"pop": 2000,
"pop_max": 4000
}
}
]
},
"observe": {
"generalIds": [1, 2],
"nationIds": [1, 2],
"cityIds": [1, 2]
}
}
@@ -0,0 +1,143 @@
{
"action": "PostUpdateMonthly",
"environment": {
"year": 195,
"month": 2,
"startyear": 193,
"seedYear": 195,
"seedMonth": 1
},
"setup": {
"world": {
"hiddenSeed": "monthly-post-wander-fixture"
},
"syncEnvironment": true,
"resetCities": true,
"city": [
{
"id": 1,
"values": {
"name": "방랑성",
"nation": 4,
"level": 4,
"front": 1
}
},
{
"id": 2,
"values": {
"name": "존속성",
"nation": 1,
"level": 4,
"front": 1
}
}
],
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2, 3],
"general": [
{
"id": 1,
"values": {
"name": "방랑주",
"nation": 4,
"city": 1,
"officer_level": 12,
"npc": 2,
"gold": 2000,
"rice": 3000,
"crew": 100,
"makelimit": 0,
"belong": 7,
"leadership": 50,
"strength": 40,
"intel": 30,
"experience": 100,
"dedication": 200
}
},
{
"id": 2,
"values": {
"name": "방랑객",
"nation": 4,
"city": 1,
"officer_level": 1,
"npc": 0,
"gold": 1500,
"rice": 4000,
"crew": 200,
"makelimit": 0,
"belong": 5,
"leadership": 60,
"strength": 50,
"intel": 40,
"experience": 300,
"dedication": 400
}
},
{
"id": 3,
"values": {
"name": "존속장",
"nation": 1,
"city": 2,
"officer_level": 12,
"npc": 2,
"gold": 500,
"rice": 500,
"crew": 50,
"leadership": 50,
"strength": 50,
"intel": 50,
"experience": 0,
"dedication": 0
}
}
],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 4],
"nation": [
{
"id": 1,
"values": {
"name": "존속국",
"gennum": 1,
"level": 1
}
},
{
"id": 4,
"values": {
"name": "방랑국",
"gennum": 2,
"level": 0
}
}
],
"resetDiplomacy": true,
"diplomacy": [
{
"fromNationId": 1,
"toNationId": 4,
"state": 2,
"term": 0,
"dead": 0
},
{
"fromNationId": 4,
"toNationId": 1,
"state": 2,
"term": 0,
"dead": 0
}
]
},
"observe": {
"generalIds": [1, 2, 3],
"nationIds": [1, 4],
"cityIds": [1, 2]
}
}
@@ -0,0 +1,49 @@
{
"action": "PreUpdateMonthly",
"environment": {
"year": 200,
"month": 12,
"startyear": 190
},
"setup": {
"syncEnvironment": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2],
"general": [
{"id": 1, "values": {"makelimit": 2}},
{"id": 2, "values": {"makelimit": 0}}
],
"generalAccessLog": [
{"generalId": 1, "refreshScoreTotal": 101},
{"generalId": 2, "refreshScoreTotal": 1}
],
"nation": [
{
"id": 1,
"values": {
"rate": 35,
"rate_tmp": 10,
"strategic_cmd_limit": 2,
"surlimit": 1,
"spy": "{\"1\":1,\"2\":2}"
}
}
],
"city": [
{"id": 1, "values": {"state": 31, "term": 1, "conflict": {"1": 10}}},
{"id": 2, "values": {"state": 32, "term": 2, "conflict": {"1": 20}}},
{"id": 3, "values": {"state": 33, "term": 0, "conflict": {"1": 30}}},
{"id": 4, "values": {"state": 34, "term": 3, "conflict": {"1": 40}}},
{"id": 5, "values": {"state": 41, "term": 1, "conflict": {"1": 50}}},
{"id": 6, "values": {"state": 42, "term": 2, "conflict": {"1": 60}}},
{"id": 7, "values": {"state": 43, "term": 3, "conflict": {"1": 70}}}
]
},
"observe": {
"generalIds": [1, 2],
"nationIds": [1],
"cityIds": [1, 2, 3, 4, 5, 6, 7],
"accessGeneralIds": [1, 2]
}
}
@@ -0,0 +1,76 @@
{
"action": "ProcessIncome",
"args": ["gold"],
"environment": {
"year": 190,
"month": 7,
"startyear": 190
},
"setup": {
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"general": [
{
"id": 1,
"values": {
"nation": 1,
"city": 1,
"officer_level": 5,
"npc": 0,
"gold": 1000,
"rice": 1000,
"dedication": 100
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "갑국",
"capital": 1,
"gold": 10000,
"rice": 20000,
"level": 1,
"rate": 35,
"rate_tmp": 10
}
}
],
"city": [
{
"id": 1,
"values": {
"name": "갑성",
"nation": 1,
"level": 4,
"supply": 1,
"pop": 10000,
"pop_max": 20000,
"agri": 1000,
"agri_max": 2000,
"comm": 1000,
"comm_max": 2000,
"secu": 1000,
"secu_max": 2000,
"trust": 80,
"def": 500,
"def_max": 1000,
"wall": 500,
"wall_max": 1000
}
}
]
},
"observe": {
"generalIds": [1],
"nationIds": [1],
"cityIds": [1],
"nationEnvironmentKeys": ["prev_income_gold", "prev_income_rice"]
}
}
@@ -0,0 +1,106 @@
{
"action": "ProcessSemiAnnual",
"resource": "gold",
"environment": {
"year": 193,
"month": 1,
"startyear": 190
},
"setup": {
"resetCities": true,
"resetGenerals": true,
"nation": [
{
"id": 1,
"values": {
"name": "반기검증국",
"gold": 100001,
"rice": 7777,
"rate_tmp": 20,
"type": "che_도가"
}
}
],
"city": [
{
"id": 1,
"values": {
"nation": 1,
"supply": 1,
"pop": 10000,
"pop_max": 50000,
"agri": 1001,
"agri_max": 5000,
"comm": 1001,
"comm_max": 5000,
"secu": 1001,
"secu_max": 2000,
"trust": 55,
"def": 1001,
"def_max": 5000,
"wall": 1001,
"wall_max": 5000,
"dead": 123
}
},
{
"id": 2,
"values": {
"nation": 1,
"supply": 0,
"pop": 10000,
"pop_max": 50000,
"agri": 1001,
"agri_max": 5000,
"comm": 1001,
"comm_max": 5000,
"secu": 1001,
"secu_max": 2000,
"trust": 55,
"def": 1001,
"def_max": 5000,
"wall": 1001,
"wall_max": 5000,
"dead": 123
}
},
{
"id": 3,
"values": {
"nation": 0,
"supply": 1,
"pop": 10000,
"pop_max": 50000,
"agri": 1001,
"agri_max": 5000,
"comm": 1001,
"comm_max": 5000,
"secu": 1001,
"secu_max": 2000,
"trust": 55,
"def": 1001,
"def_max": 5000,
"wall": 1001,
"wall_max": 5000,
"dead": 123
}
}
],
"general": [
{
"id": 1,
"values": {
"nation": 1,
"city": 1,
"gold": 10001,
"rice": 8888
}
}
]
},
"observe": {
"generalIds": [1],
"cityIds": [1, 2, 3],
"nationIds": [1]
}
}
@@ -0,0 +1,65 @@
{
"action": "ProcessSemiAnnual",
"resource": "gold",
"environment": {
"year": 193,
"month": 1,
"startyear": 190
},
"setup": {
"resetCities": true,
"resetGenerals": true,
"nation": [
{
"id": 1,
"values": {
"name": "반기검증국",
"gold": 100001,
"rice": 7777,
"rate": 50,
"rate_tmp": 20,
"type": "che_중립"
}
}
],
"city": [
{
"id": 1,
"values": {
"nation": 1,
"supply": 1,
"pop": 10000,
"pop_max": 50000,
"agri": 1001,
"agri_max": 5000,
"comm": 1001,
"comm_max": 5000,
"secu": 1001,
"secu_max": 2000,
"trust": 55,
"def": 1001,
"def_max": 5000,
"wall": 1001,
"wall_max": 5000,
"dead": 123
}
}
],
"general": [
{
"id": 1,
"values": {
"nation": 1,
"city": 1,
"gold": 10001,
"rice": 8888
}
}
]
},
"observe": {
"generalIds": [1],
"cityIds": [1],
"nationIds": [1]
}
}
@@ -0,0 +1,96 @@
{
"action": "ProcessWarIncome",
"environment": {
"year": 193,
"month": 1,
"startyear": 190
},
"setup": {
"resetCities": true,
"nation": [
{
"id": 1,
"values": {
"name": "전쟁수입국",
"gold": 1000,
"level": 1,
"type": "che_중립"
}
},
{
"id": 2,
"values": {
"name": "비활성국",
"gold": 2000,
"level": 0,
"type": "che_중립"
}
},
{
"id": 3,
"values": {
"name": "법가국",
"gold": 3000,
"level": 1,
"type": "che_법가"
}
}
],
"city": [
{
"id": 1,
"values": {
"nation": 1,
"supply": 1,
"pop": 1000,
"pop_max": 2000,
"dead": 101
}
},
{
"id": 2,
"values": {
"nation": 1,
"supply": 0,
"pop": 1000,
"pop_max": 2000,
"dead": 999
}
},
{
"id": 3,
"values": {
"nation": 2,
"supply": 1,
"pop": 1000,
"pop_max": 2000,
"dead": 1000
}
},
{
"id": 4,
"values": {
"nation": 3,
"supply": 1,
"pop": 1000,
"pop_max": 2000,
"dead": 105
}
},
{
"id": 5,
"values": {
"nation": 0,
"supply": 1,
"pop": 999,
"pop_max": 1000,
"dead": 10
}
}
]
},
"observe": {
"cityIds": [1, 2, 3, 4, 5],
"nationIds": [1, 2, 3]
}
}
@@ -0,0 +1,105 @@
{
"action": "RaiseInvader",
"compactOutput": true,
"args": [10, 150, 100, 20],
"setup": {
"resetCities": true,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"city": [
{
"id": 1,
"values": {
"level": 3,
"nation": 1,
"pop": 1000,
"pop_max": 10000,
"agri": 2000,
"agri_max": 20000,
"comm": 3000,
"comm_max": 30000,
"secu": 4000,
"secu_max": 40000,
"def": 5000,
"def_max": 50000,
"wall": 6000,
"wall_max": 60000
}
},
{
"id": 48,
"values": {
"level": 4,
"nation": 0,
"pop": 1000,
"pop_max": 10000,
"agri": 2000,
"agri_max": 20000,
"comm": 3000,
"comm_max": 30000,
"secu": 4000,
"secu_max": 40000,
"def": 5000,
"def_max": 50000,
"wall": 6000,
"wall_max": 60000
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "기준국",
"capital": 1,
"gennum": 1,
"tech": 100,
"level": 2,
"type": "che_유가"
}
}
],
"general": [
{
"id": 1,
"values": {
"name": "군주",
"nation": 1,
"city": 1,
"officer_level": 12,
"npc": 0,
"leadership": 50,
"strength": 50,
"intel": 50,
"experience": 1000,
"dedication": 1000,
"dex1": 10,
"dex2": 20,
"dex3": 30,
"dex4": 40,
"dex5": 50,
"turntime": "0200-01-01 00:05:00"
}
}
],
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {
"generalIds": [1],
"cityIds": [1, 48],
"nationIds": [1]
}
}
@@ -0,0 +1,77 @@
{
"action": "RaiseNPCNation",
"setup": {
"resetCities": true,
"resetGenerals": true,
"resetNations": true,
"city": [
{
"id": 1,
"values": {
"name": "점유도시",
"level": 5,
"nation": 1,
"pop": 10001,
"pop_max": 50001,
"agri": 1001,
"agri_max": 5001,
"comm": 2001,
"comm_max": 6001,
"secu": 3001,
"secu_max": 7001,
"def": 4001,
"def_max": 8001,
"wall": 5001,
"wall_max": 9001
}
},
{
"id": 48,
"values": {
"name": "건국도시",
"level": 5,
"nation": 0,
"pop": 10048,
"pop_max": 50048,
"agri": 1048,
"agri_max": 5048,
"comm": 2048,
"comm_max": 6048,
"secu": 3048,
"secu_max": 7048,
"def": 4048,
"def_max": 8048,
"wall": 5048,
"wall_max": 9048
}
}
],
"nation": [
{
"id": 1,
"values": {
"name": "기준국",
"capital": 1,
"gennum": 1,
"tech": 120,
"level": 2,
"type": "che_유가"
}
}
],
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {
"cityIds": [1, 48],
"nationIds": [1]
}
}
@@ -0,0 +1,35 @@
{
"action": "RegNeutralNPC",
"args": [
0,
"등록재야",
null,
0,
33,
45,
55,
65,
180,
245,
null,
"무쌍",
""
],
"setup": {
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {
"generalIds": [],
"cityIds": [33],
"nationIds": [1]
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"action": "RegNPC",
"args": [
0,
"등록장수",
null,
0,
33,
60,
50,
40,
7,
186,
240,
null,
"인덕",
"등록 대사"
],
"setup": {
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"fiction": [0]
},
"observe": {
"generalIds": [],
"cityIds": [33],
"nationIds": [1]
}
}
@@ -0,0 +1,24 @@
{
"action": "UnblockScoutAction",
"args": [false],
"setup": {
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1, 2],
"nation": [
{ "id": 1, "values": { "name": "위", "level": 2, "scout": 1 } },
{ "id": 2, "values": { "name": "촉", "level": 2, "scout": 1 } }
],
"gameEnvironment": {
"block_change_scout": true
}
},
"environment": {
"year": 200,
"month": 1,
"startyear": 190
},
"observe": {
"nationIds": [1, 2]
}
}
@@ -0,0 +1,140 @@
{
"action": "UpdateCitySupply",
"environment": {
"year": 193,
"month": 1,
"startyear": 190
},
"setup": {
"nation": [
{
"id": 99,
"values": {
"name": "보급검증국",
"color": "#777777",
"capital": 1,
"level": 1,
"type": "che_중립"
}
},
{
"id": 98,
"values": {
"name": "불일치수도국",
"color": "#888888",
"capital": 2,
"level": 1,
"type": "che_중립"
}
}
],
"city": [
{
"id": 1,
"values": {
"nation": 99,
"supply": 0,
"front": 2,
"pop": 1001,
"agri": 501,
"comm": 499,
"secu": 99,
"trust": 50,
"def": 101,
"wall": 50,
"officer_set": 7,
"term": 2,
"conflict": {"98": 3}
}
},
{
"id": 9,
"values": {
"nation": 99,
"supply": 0,
"front": 2,
"pop": 1001,
"agri": 501,
"comm": 499,
"secu": 99,
"trust": 50,
"def": 101,
"wall": 50,
"officer_set": 7,
"term": 2,
"conflict": {"98": 3}
}
},
{
"id": 2,
"values": {
"nation": 99,
"supply": 1,
"front": 2,
"pop": 1001,
"agri": 501,
"comm": 499,
"secu": 99,
"trust": 33,
"def": 101,
"wall": 50,
"officer_set": 7,
"term": 2,
"conflict": {"98": 3}
}
},
{
"id": 3,
"values": {
"nation": 0,
"supply": 0,
"front": 0,
"trust": 50
}
}
],
"general": [
{
"id": 1,
"values": {
"nation": 99,
"city": 2,
"officer_level": 4,
"officer_city": 2,
"crew": 101,
"train": 51,
"atmos": 99
}
},
{
"id": 2,
"values": {
"nation": 99,
"city": 1,
"officer_level": 4,
"officer_city": 2,
"crew": 101,
"train": 51,
"atmos": 99
}
},
{
"id": 3,
"values": {
"nation": 98,
"city": 2,
"officer_level": 3,
"officer_city": 1,
"crew": 101,
"train": 51,
"atmos": 99
}
}
]
},
"observe": {
"generalIds": [1, 2, 3],
"cityIds": [1, 2, 3, 9],
"nationIds": [98, 99]
}
}
@@ -0,0 +1,85 @@
{
"action": "UpdateNationLevel",
"environment": {
"year": 193,
"month": 2,
"startyear": 190,
"killturn": 1000,
"turnterm": 10
},
"setup": {
"resetCities": true,
"resetGenerals": true,
"resetUniqueOccupancy": true,
"syncEnvironment": true,
"clearNationTurnIds": [1],
"clearInheritanceOwnerIds": [990061],
"nation": [
{
"id": 1,
"values": {
"name": "작위검증국",
"color": "#777777",
"capital": 1,
"gold": 10000,
"rice": 20000,
"level": 0,
"type": "che_중립",
"aux": {}
}
}
],
"city": [
{
"id": 1,
"values": {
"nation": 1,
"level": 4
}
},
{
"id": 2,
"values": {
"nation": 1,
"level": 4
}
}
],
"general": [
{
"id": 1,
"values": {
"name": "조조",
"nation": 1,
"city": 1,
"officer_level": 12,
"owner": 990061,
"npc": 0,
"killturn": 1000,
"belong": 10,
"horse": "None",
"weapon": "None",
"book": "None",
"item": "None",
"aux": {}
}
},
{
"id": 2,
"values": {
"nation": 0,
"city": 2,
"officer_level": 1,
"npc": 0,
"killturn": 0
}
}
]
},
"observe": {
"generalIds": [1],
"cityIds": [1, 2],
"nationIds": [1],
"ownerIds": [990061]
}
}
@@ -0,0 +1,242 @@
{
"action": "UnificationInvaderResume",
"winnerGeneralId": 1,
"setup": {
"resetCities": true,
"setAllCitiesNation": 1,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [
1,
2,
3,
4,
5,
6,
7,
8
],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [
1
],
"resetDiplomacy": true,
"resetEvents": true,
"clearInheritanceOwnerIds": [
1
],
"inheritancePrevious": {
"1": 0
},
"nation": [
{
"id": 1,
"values": {
"name": "재현국",
"color": "#336699",
"capital": 1,
"gennum": 8,
"gold": 100000,
"rice": 100000,
"tech": 1000,
"power": 1000,
"level": 2,
"type": "che_유가",
"aux": {}
}
}
],
"general": [
{
"id": 1,
"values": {
"name": "재현군주",
"nation": 1,
"city": 1,
"officer_level": 12,
"owner": 1,
"npc": 0,
"age": 30,
"bornyear": 170,
"deadyear": 260,
"belong": 240,
"killturn": 24,
"dex1": 1000,
"dex2": 1000,
"dex3": 1000,
"dex4": 1000,
"dex5": 1000,
"aux": {}
}
},
{
"id": 2,
"values": {
"name": "재현관직11",
"nation": 1,
"city": 1,
"officer_level": 11,
"owner": 0,
"npc": 2,
"killturn": 24,
"dex1": 1000,
"dex2": 1000,
"dex3": 1000,
"dex4": 1000,
"dex5": 1000,
"aux": {}
}
},
{
"id": 3,
"values": {
"name": "재현관직10",
"nation": 1,
"city": 1,
"officer_level": 10,
"owner": 0,
"npc": 2,
"killturn": 24,
"dex1": 1000,
"dex2": 1000,
"dex3": 1000,
"dex4": 1000,
"dex5": 1000,
"aux": {}
}
},
{
"id": 4,
"values": {
"name": "재현관직9",
"nation": 1,
"city": 1,
"officer_level": 9,
"owner": 0,
"npc": 2,
"killturn": 24,
"dex1": 1000,
"dex2": 1000,
"dex3": 1000,
"dex4": 1000,
"dex5": 1000,
"aux": {}
}
},
{
"id": 5,
"values": {
"name": "재현관직8",
"nation": 1,
"city": 1,
"officer_level": 8,
"owner": 0,
"npc": 2,
"killturn": 24,
"dex1": 1000,
"dex2": 1000,
"dex3": 1000,
"dex4": 1000,
"dex5": 1000,
"aux": {}
}
},
{
"id": 6,
"values": {
"name": "재현관직7",
"nation": 1,
"city": 1,
"officer_level": 7,
"owner": 0,
"npc": 2,
"killturn": 24,
"dex1": 1000,
"dex2": 1000,
"dex3": 1000,
"dex4": 1000,
"dex5": 1000,
"aux": {}
}
},
{
"id": 7,
"values": {
"name": "재현관직6",
"nation": 1,
"city": 1,
"officer_level": 6,
"owner": 0,
"npc": 2,
"killturn": 24,
"dex1": 1000,
"dex2": 1000,
"dex3": 1000,
"dex4": 1000,
"dex5": 1000,
"aux": {}
}
},
{
"id": 8,
"values": {
"name": "재현관직5",
"nation": 1,
"city": 1,
"officer_level": 5,
"owner": 0,
"npc": 2,
"killturn": 24,
"dex1": 1000,
"dex2": 1000,
"dex3": 1000,
"dex4": 1000,
"dex5": 1000,
"aux": {}
}
}
],
"nationEnvironment": [
{
"nationId": 1,
"key": "nationNotice",
"value": {
"msg": "재현"
}
},
{
"nationId": 1,
"key": "scout_msg",
"value": "재현"
},
{
"nationId": 1,
"key": "max_power",
"value": {}
}
],
"gameEnvironment": {
"isunited": 0,
"refreshLimit": 30,
"block_change_scout": true
},
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 180,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"icon_path": ".",
"fiction": [
0
]
},
"preSummonGeneralCopies": 350,
"invaderChoiceIndex": 0,
"stopAfterSummon": true,
"maxSummonElapsedMs": 30000
}
@@ -0,0 +1,91 @@
{
"action": "UnificationInvaderResume",
"winnerGeneralId": 1,
"setup": {
"resetCities": true,
"setAllCitiesNation": 1,
"resetGenerals": true,
"deleteOtherGenerals": true,
"keepGeneralIds": [1, 2, 3, 4, 5, 6, 7, 8],
"resetNations": true,
"deleteOtherNations": true,
"keepNationIds": [1],
"resetDiplomacy": true,
"resetEvents": true,
"clearInheritanceOwnerIds": [1],
"inheritancePrevious": {
"1": 0
},
"nation": [
{
"id": 1,
"values": {
"name": "재현국",
"color": "#336699",
"capital": 1,
"gennum": 8,
"gold": 100000,
"rice": 100000,
"tech": 1000,
"power": 1000,
"level": 2,
"type": "che_유가",
"aux": {}
}
}
],
"general": [
{
"id": 1,
"values": {
"name": "재현군주",
"nation": 1,
"city": 1,
"officer_level": 12,
"owner": 1,
"npc": 0,
"age": 30,
"bornyear": 170,
"deadyear": 260,
"belong": 240,
"killturn": 24,
"dex1": 1000,
"dex2": 1000,
"dex3": 1000,
"dex4": 1000,
"dex5": 1000,
"aux": {}
}
},
{ "id": 2, "values": { "name": "재현관직11", "nation": 1, "city": 1, "officer_level": 11, "owner": 0, "npc": 2, "killturn": 24, "dex1": 1000, "dex2": 1000, "dex3": 1000, "dex4": 1000, "dex5": 1000, "aux": {} } },
{ "id": 3, "values": { "name": "재현관직10", "nation": 1, "city": 1, "officer_level": 10, "owner": 0, "npc": 2, "killturn": 24, "dex1": 1000, "dex2": 1000, "dex3": 1000, "dex4": 1000, "dex5": 1000, "aux": {} } },
{ "id": 4, "values": { "name": "재현관직9", "nation": 1, "city": 1, "officer_level": 9, "owner": 0, "npc": 2, "killturn": 24, "dex1": 1000, "dex2": 1000, "dex3": 1000, "dex4": 1000, "dex5": 1000, "aux": {} } },
{ "id": 5, "values": { "name": "재현관직8", "nation": 1, "city": 1, "officer_level": 8, "owner": 0, "npc": 2, "killturn": 24, "dex1": 1000, "dex2": 1000, "dex3": 1000, "dex4": 1000, "dex5": 1000, "aux": {} } },
{ "id": 6, "values": { "name": "재현관직7", "nation": 1, "city": 1, "officer_level": 7, "owner": 0, "npc": 2, "killturn": 24, "dex1": 1000, "dex2": 1000, "dex3": 1000, "dex4": 1000, "dex5": 1000, "aux": {} } },
{ "id": 7, "values": { "name": "재현관직6", "nation": 1, "city": 1, "officer_level": 6, "owner": 0, "npc": 2, "killturn": 24, "dex1": 1000, "dex2": 1000, "dex3": 1000, "dex4": 1000, "dex5": 1000, "aux": {} } },
{ "id": 8, "values": { "name": "재현관직5", "nation": 1, "city": 1, "officer_level": 5, "owner": 0, "npc": 2, "killturn": 24, "dex1": 1000, "dex2": 1000, "dex3": 1000, "dex4": 1000, "dex5": 1000, "aux": {} } }
],
"nationEnvironment": [
{ "nationId": 1, "key": "nationNotice", "value": { "msg": "재현" } },
{ "nationId": 1, "key": "scout_msg", "value": "재현" },
{ "nationId": 1, "key": "max_power", "value": {} }
],
"gameEnvironment": {
"isunited": 0,
"refreshLimit": 30,
"block_change_scout": true
},
"syncEnvironment": true
},
"environment": {
"year": 200,
"month": 1,
"startyear": 180,
"turnterm": 10,
"turntime": "0200-01-01 00:00:00",
"show_img_level": 3,
"stored_icons": [],
"icon_path": ".",
"fiction": [0]
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli' || getenv('REF_DETERMINISTIC_INSTALL_ENABLED') !== '1') {
http_response_code(404);
exit(1);
}
chdir(dirname(__DIR__));
require_once 'lib.php';
require_once 'func.php';
$request = json_decode(stream_get_contents(STDIN), true, flags: JSON_THROW_ON_ERROR);
$generalIds = $request['generalIds'] ?? null;
if (!is_array($generalIds) || $generalIds === []) {
throw new \InvalidArgumentException('generalIds must be a non-empty array');
}
$db = DB::db();
$gameStorage = KVStorage::getStorage($db, 'game_env');
$env = $gameStorage->getAll(true);
$result = [];
$includeNation = ($request['includeNation'] ?? false) === true;
foreach ($generalIds as $generalId) {
if (!is_int($generalId) || $generalId < 1) {
throw new \InvalidArgumentException('generalIds must contain positive integers');
}
$general = General::createObjFromDB($generalId);
if ($general->getNPCType() < 2) {
throw new \InvalidArgumentException("general {$generalId} is not an NPC");
}
$ai = new GeneralAI($general);
$nationDecision = null;
if ($includeNation && $general->getNationID() !== 0 && $general->getVar('officer_level') >= 5) {
$nationStorage = KVStorage::getStorage($db, $general->getNationID(), 'nation_env');
$lastNationTurn = LastTurn::fromRaw($nationStorage->getValue("turn_last_{$general->getVar('officer_level')}"));
$reservedNation = buildNationCommandClass(null, $general, $env, $lastNationTurn);
$selectedNation = $ai->chooseNationTurn($reservedNation);
$nationDecision = [
'action' => $selectedNation->getRawClassName(),
'args' => $selectedNation->getArg(),
'reason' => $selectedNation->reason,
];
}
$reserved = $general->getReservedTurn(0, $env);
$selected = $ai->chooseGeneralTurn($reserved);
$result[] = [
'generalId' => $generalId,
'name' => $general->getName(),
'action' => $selected->getRawClassName(),
'brief' => $selected->getBrief(),
'args' => $selected->getArg(),
'reason' => $selected->reason,
'nationDecision' => $nationDecision,
];
}
echo json_encode([
'year' => (int) $env['year'],
'month' => (int) $env['month'],
'decisions' => $result,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace sammo;
use sammo\API\Message\DecideMessageResponse;
use sammo\Enums\MessageType;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
if (getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
fwrite(STDERR, "TURN_DIFFERENTIAL_ENABLED=1 is required\n");
exit(1);
}
define('SAMMO_TURN_COMPARISON_LIBRARY_ONLY', true);
require_once __DIR__ . '/turn_command_trace.php';
/** @return array<string, mixed> */
function comparisonInstantDiplomacyMessageRow(int $messageId): array
{
$row = DB::db()->queryFirstRow(
'SELECT id, mailbox, type, src, dest, time, valid_until, message FROM message WHERE id = %i',
$messageId,
);
if ($row === null) {
throw new \RuntimeException("message {$messageId} was not persisted");
}
return [
'id' => (int)$row['id'],
'mailbox' => (int)$row['mailbox'],
'type' => (string)$row['type'],
'sourceId' => (int)$row['src'],
'destinationId' => (int)$row['dest'],
'timeTick' => (int)$row['time'],
'validUntilTick' => (int)$row['valid_until'],
'payload' => comparisonJsonValue($row['message']),
];
}
/** @return array<string, mixed> */
function comparisonRunInstantDiplomacyResponse(array $request): array
{
$action = $request['action'] ?? null;
if (!in_array($action, [
DiplomaticMessage::TYPE_NO_AGGRESSION,
DiplomaticMessage::TYPE_CANCEL_NA,
DiplomaticMessage::TYPE_STOP_WAR,
], true)) {
throw new \InvalidArgumentException('action must be noAggression, cancelNA, or stopWar');
}
$actorGeneralId = $request['actorGeneralId'] ?? null;
$proposerGeneralId = $request['proposerGeneralId'] ?? null;
if (!is_int($actorGeneralId) || $actorGeneralId < 1) {
throw new \InvalidArgumentException('actorGeneralId must be a positive integer');
}
if (!is_int($proposerGeneralId) || $proposerGeneralId < 1) {
throw new \InvalidArgumentException('proposerGeneralId must be a positive integer');
}
if (($request['response'] ?? true) !== true) {
throw new \InvalidArgumentException('this comparison runner currently covers accepted responses only');
}
comparisonApplyTurnFixtureSetup($request['setup'] ?? null);
$db = DB::db();
$actor = $db->queryFirstRow(
'SELECT no, name, nation FROM general WHERE no = %i',
$actorGeneralId,
);
$proposer = $db->queryFirstRow(
'SELECT no, name, nation FROM general WHERE no = %i',
$proposerGeneralId,
);
if ($actor === null || $proposer === null) {
throw new \RuntimeException('fixture generals are missing');
}
$actorNation = $db->queryFirstRow(
'SELECT nation, name, color FROM nation WHERE nation = %i',
(int)$actor['nation'],
);
$proposerNation = $db->queryFirstRow(
'SELECT nation, name, color FROM nation WHERE nation = %i',
(int)$proposer['nation'],
);
if ($actorNation === null || $proposerNation === null) {
throw new \RuntimeException('fixture nations are missing');
}
$messageBaseline = (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM message') ?? 0);
$logBaseline = (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM general_record') ?? 0);
$src = new MessageTarget(
$proposerGeneralId,
(string)$proposer['name'],
(int)$proposerNation['nation'],
(string)$proposerNation['name'],
(string)$proposerNation['color'],
);
$dest = new MessageTarget(
$actorGeneralId,
(string)$actor['name'],
(int)$actorNation['nation'],
(string)$actorNation['name'],
(string)$actorNation['color'],
);
$option = ['action' => $action];
if ($action === DiplomaticMessage::TYPE_NO_AGGRESSION) {
$year = $request['year'] ?? null;
$month = $request['month'] ?? null;
if (!is_int($year) || !is_int($month)) {
throw new \InvalidArgumentException('noAggression requires integer year and month');
}
$option['year'] = $year;
$option['month'] = $month;
}
$proposal = new DiplomaticMessage(
MessageType::diplomacy,
$src,
$dest,
'외교 제안',
Message::gameNow(),
new \DateTime('9999-12-31'),
$option,
);
$proposalMessageId = $proposal->send();
$proposalBefore = comparisonInstantDiplomacyMessageRow($proposalMessageId);
$observe = $request['observe'] ?? [];
if (!is_array($observe)) {
throw new \InvalidArgumentException('observe must be an object');
}
$observe['logAfterId'] = $logBaseline;
$observe['messageAfterId'] = $messageBaseline;
$snapshotRequest = ['observe' => $observe];
$before = comparisonTurnStateSnapshot($snapshotRequest);
$session = Session::getInstance();
$_SESSION[UniqueConst::$serverID . Session::GAME_KEY_GENERAL_ID] = $actorGeneralId;
$_SESSION[UniqueConst::$serverID . Session::GAME_KEY_GENERAL_NAME] = (string)$actor['name'];
$api = new DecideMessageResponse(dirname(__DIR__), [
'msgID' => $proposalMessageId,
'response' => true,
]);
$validationError = $api->validateArgs();
if ($validationError !== null) {
throw new \RuntimeException("DecideMessageResponse validation failed: {$validationError}");
}
$apiResult = $api->launch($session, null, null);
if (!is_array($apiResult)) {
throw new \RuntimeException('DecideMessageResponse did not return an array result');
}
return [
'schemaVersion' => 1,
'engine' => 'ref',
'execution' => [
'kind' => 'instantDiplomacyMessageResponse',
'entryPoint' => DecideMessageResponse::class,
'actorGeneralId' => $actorGeneralId,
'proposerGeneralId' => $proposerGeneralId,
'action' => $action,
'response' => true,
'outcome' => $apiResult,
'proposalMessageId' => $proposalMessageId,
'proposalBefore' => $proposalBefore,
'proposalAfter' => comparisonInstantDiplomacyMessageRow($proposalMessageId),
],
'before' => $before,
'after' => comparisonTurnStateSnapshot($snapshotRequest),
'rng' => [],
];
}
try {
$input = stream_get_contents(STDIN);
$request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($request)) {
throw new \InvalidArgumentException('request must be an object');
}
echo json_encode(
comparisonRunInstantDiplomacyResponse($request),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
exit(1);
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
require_once 'lib.php';
require_once 'func.php';
$input = stream_get_contents(STDIN);
$keys = Json::decode($input);
if (!is_array($keys)) {
fwrite(STDERR, "Expected a JSON array of item keys.\n");
exit(2);
}
$result = [];
foreach ($keys as $key) {
if (!is_string($key)) {
continue;
}
$item = buildItemClass($key);
$result[$key] = [
'rawName' => $item->getRawName(),
'name' => $item->getName(),
'info' => $item->getInfo(),
'cost' => $item->getCost(),
'buyable' => $item->isBuyable(),
'consumable' => $item->isConsumable(),
'reqSecu' => $item->getReqSecu(),
];
}
echo Json::encode($result);
+1900
View File
@@ -0,0 +1,1900 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli' || getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
error_reporting(E_ALL & ~E_DEPRECATED);
require_once 'lib.php';
require_once 'func.php';
require_once __DIR__ . '/turn_state_snapshot.php';
function comparisonGameTick(mixed $value, string $label): int
{
if (is_int($value)) {
return GameClock::requireSafeTick($value);
}
if (!is_string($value) || $value === '') {
throw new \InvalidArgumentException("{$label} must be a game tick or projected date string");
}
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
return GameClock::fromStorage($gameStorage)->dateTimeToTick(new \DateTimeImmutable($value));
}
/** @param array<string, mixed> $values */
function comparisonMonthlyPatch(string $table, int $id, array $values): void
{
$definitions = [
'city' => [
'idColumn' => 'city',
'allowed' => [
'name', 'level', 'nation', 'supply', 'front', 'pop', 'pop_max',
'agri', 'agri_max', 'comm', 'comm_max', 'secu', 'secu_max',
'trust', 'trade', 'def', 'def_max', 'wall', 'wall_max',
'officer_set', 'state', 'region', 'term', 'conflict', 'dead',
],
],
'nation' => [
'idColumn' => 'nation',
'allowed' => [
'name', 'color', 'capital', 'gennum', 'gold', 'rice', 'tech',
'power', 'level', 'type', 'bill', 'rate_tmp', 'scout', 'chief_set', 'aux',
'rate', 'strategic_cmd_limit', 'surlimit', 'spy',
],
],
'general' => [
'idColumn' => 'no',
'allowed' => [
'name', 'nation', 'city', 'officer_level', 'officer_city',
'crew', 'train', 'atmos', 'owner', 'npc', 'killturn', 'belong',
'gold', 'rice', 'horse', 'weapon', 'book', 'item', 'aux',
'leadership', 'strength', 'intel', 'experience', 'dedication',
'injury',
'dex1', 'dex2', 'dex3', 'dex4', 'dex5', 'personal', 'special',
'specage', 'special2', 'specage2', 'betray', 'age',
'bornyear', 'deadyear', 'turntime', 'makelimit',
],
],
];
$definition = $definitions[$table] ?? null;
if ($definition === null) {
throw new \InvalidArgumentException("unsupported setup table: {$table}");
}
$patch = [];
foreach ($values as $key => $value) {
if (!is_string($key) || !in_array($key, $definition['allowed'], true)) {
throw new \InvalidArgumentException("unsupported {$table} setup field");
}
if ($table === 'general' && $key === 'turntime' && is_string($value)) {
$value = comparisonGameTick($value, "setup.general.{$id}.turntime");
}
if (in_array($key, ['conflict', 'aux'], true) && is_array($value)) {
$value = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
}
$patch[$key] = $value;
}
if ($table === 'nation') {
$patch += [
'nation' => $id,
'name' => "fixture-nation-{$id}",
'color' => '#777777',
'capital' => 0,
'gold' => 0,
'rice' => 0,
'level' => 1,
'type' => 'che_중립',
'aux' => '{}',
];
DB::db()->insertUpdate('nation', $patch, $patch);
} elseif ($patch !== []) {
DB::db()->update($table, $patch, "`{$definition['idColumn']}` = %i", $id);
}
}
/** @param array<string, mixed> $observe */
function comparisonMonthlyDetails(array $observe, int $worldHistoryAfterId): array
{
$db = DB::db();
$generals = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'no',
'nationId' => 'nation',
'cityId' => 'city',
'officerLevel' => 'officer_level',
'officerCityId' => 'officer_city',
'crew' => 'crew',
'train' => 'train',
'atmos' => 'atmos',
'gold' => 'gold',
'rice' => 'rice',
'injury' => 'injury',
'leadership' => 'leadership',
'strength' => 'strength',
'intelligence' => 'intel',
'age' => 'age',
'specialDomestic' => 'special',
'specAge' => 'specage',
'specialWar' => 'special2',
'specAge2' => 'specage2',
'betray' => 'betray',
'makeLimit' => 'makelimit',
'horse' => 'horse',
'weapon' => 'weapon',
'book' => 'book',
'item' => 'item',
'dex1' => 'dex1',
'dex2' => 'dex2',
'dex3' => 'dex3',
'dex4' => 'dex4',
'dex5' => 'dex5',
'aux' => 'aux',
],
['aux'],
),
comparisonRowsById('general', 'no', comparisonIntegerList($observe['generalIds'] ?? [], 'generalIds')),
);
$cities = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'city',
'nationId' => 'nation',
'state' => 'state',
'supplyState' => 'supply',
'frontState' => 'front',
'officerSet' => 'officer_set',
'term' => 'term',
'dead' => 'dead',
'population' => 'pop',
'populationMax' => 'pop_max',
'agriculture' => 'agri',
'agricultureMax' => 'agri_max',
'commerce' => 'comm',
'commerceMax' => 'comm_max',
'security' => 'secu',
'securityMax' => 'secu_max',
'trust' => 'trust',
'trade' => 'trade',
'defence' => 'def',
'defenceMax' => 'def_max',
'wall' => 'wall',
'wallMax' => 'wall_max',
'conflict' => 'conflict',
],
['conflict'],
),
comparisonRowsById('city', 'city', comparisonIntegerList($observe['cityIds'] ?? [], 'cityIds')),
);
$nations = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'nation',
'gold' => 'gold',
'rice' => 'rice',
'rate' => 'rate_tmp',
'baseRate' => 'rate',
'power' => 'power',
'generalCount' => 'gennum',
'tech' => 'tech',
'typeCode' => 'type',
'scout' => 'scout',
'strategicCommandLimit' => 'strategic_cmd_limit',
'surrenderLimit' => 'surlimit',
'spy' => 'spy',
'chiefSet' => 'chief_set',
],
['spy'],
),
comparisonRowsById('nation', 'nation', comparisonIntegerList($observe['nationIds'] ?? [], 'nationIds')),
);
$worldHistory = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'id',
'nationId' => 'nation_id',
'year' => 'year',
'month' => 'month',
'text' => 'text',
],
),
$db->query(
'SELECT id, nation_id, year, month, text FROM world_history WHERE id > %i ORDER BY id',
$worldHistoryAfterId,
),
);
$inheritanceKeys = $observe['inheritanceKeys'] ?? ['unifier'];
if (!is_array($inheritanceKeys) || array_filter($inheritanceKeys, static fn($key): bool => !is_string($key))) {
throw new \InvalidArgumentException('inheritanceKeys must be an array of strings');
}
$inheritancePoints = [];
foreach (comparisonIntegerList($observe['ownerIds'] ?? [], 'ownerIds') as $ownerId) {
$entry = ['ownerId' => $ownerId];
foreach ($inheritanceKeys as $key) {
$value = $db->queryFirstField(
'SELECT value FROM storage WHERE namespace = %s AND `key` = %s',
"inheritance_{$ownerId}",
$key,
);
$entry[$key] = $value === null ? 0 : (comparisonJsonValue($value)[0] ?? 0);
}
$inheritancePoints[] = $entry;
}
$rankGeneralIds = comparisonIntegerList($observe['rankGeneralIds'] ?? [], 'rankGeneralIds');
$rankTypes = $observe['rankTypes'] ?? [];
if (!is_array($rankTypes) || array_filter($rankTypes, static fn($type): bool => !is_string($type))) {
throw new \InvalidArgumentException('rankTypes must be an array of strings');
}
$rankData = (!$rankGeneralIds || !$rankTypes)
? []
: iterator_to_array($db->query(
'SELECT general_id AS generalId, nation_id AS nationId, `type`, `value` FROM rank_data WHERE general_id IN %li AND `type` IN %ls ORDER BY general_id, `type`',
$rankGeneralIds,
$rankTypes,
));
$generalAccessLogs = [];
$accessGeneralIds = comparisonIntegerList($observe['accessGeneralIds'] ?? [], 'accessGeneralIds');
if ($accessGeneralIds) {
$generalAccessLogs = iterator_to_array($db->query(
'SELECT general_id AS generalId, refresh_score_total AS refreshScoreTotal FROM general_access_log WHERE general_id IN %li ORDER BY general_id',
$accessGeneralIds,
));
}
$nationEnvironment = [];
$nationEnvironmentKeys = $observe['nationEnvironmentKeys'] ?? [];
if (
!is_array($nationEnvironmentKeys)
|| array_filter($nationEnvironmentKeys, static fn($key): bool => !is_string($key))
) {
throw new \InvalidArgumentException('nationEnvironmentKeys must be an array of strings');
}
foreach (comparisonIntegerList($observe['nationIds'] ?? [], 'nationIds') as $nationId) {
$entry = ['nationId' => $nationId];
$storage = KVStorage::getStorage($db, $nationId, 'nation_env');
foreach ($nationEnvironmentKeys as $key) {
$entry[$key] = $storage->getValue($key);
}
$nationEnvironment[] = $entry;
}
return [
'generals' => $generals,
'cities' => $cities,
'nations' => $nations,
'worldHistory' => $worldHistory,
'inheritancePoints' => $inheritancePoints,
'rankData' => $rankData,
'generalAccessLogs' => $generalAccessLogs,
'nationEnvironment' => $nationEnvironment,
];
}
/**
* Exercise the dynamically-created invader events against one evolving database.
*
* @param array<string, mixed> $request
* @param array<string, mixed> $environment
* @return array<string, mixed>
*/
function comparisonInvaderLifecycleTrace(
array $request,
array $environment,
int $generalIdBeforeAction,
int $nationIdBeforeAction,
int $eventIdBeforeAction,
int $worldHistoryIdBeforeAction,
): array {
$db = DB::db();
$args = $request['args'] ?? [];
if (!is_array($args) || count($args) > 4) {
throw new \InvalidArgumentException('InvaderLifecycle args must have at most four entries');
}
$year = $environment['year'];
$month = $environment['month'];
$startYear = $environment['startyear'];
$raiseEnvironment = [
'year' => $year,
'month' => $month,
'startyear' => $startYear,
'turnterm' => $environment['turnterm'],
'turntime' => $environment['turntime'],
'show_img_level' => $environment['show_img_level'] ?? 3,
'stored_icons' => $environment['stored_icons'] ?? [],
'icon_path' => $environment['icon_path'] ?? '.',
'fiction' => $environment['fiction'] ?? [0],
];
(new \sammo\Event\Action\RaiseInvader(...$args))->run($raiseEnvironment);
$invaderNationIds = array_map(
'intval',
$db->queryFirstColumn('SELECT nation FROM nation WHERE nation > %i ORDER BY nation', $nationIdBeforeAction),
);
$existingNationId = (int)$db->queryFirstField(
'SELECT nation FROM nation WHERE nation <= %i ORDER BY nation LIMIT 1',
$nationIdBeforeAction,
);
$rulerIds = array_map(
'intval',
$db->queryFirstColumn(
'SELECT no FROM general WHERE nation IN %li AND officer_level = 12 ORDER BY nation',
$invaderNationIds,
),
);
if ($invaderNationIds === [] || $existingNationId <= 0 || count($rulerIds) !== count($invaderNationIds)) {
throw new \RuntimeException('InvaderLifecycle failed to create the expected entities');
}
$autoDeleteEvents = [];
$endingEventId = 0;
foreach ($db->query('SELECT id, action FROM event WHERE id > %i ORDER BY id', $eventIdBeforeAction) as $row) {
$action = comparisonJsonValue($row['action']);
$actionName = $action[0][0] ?? null;
if ($actionName === 'AutoDeleteInvader') {
$autoDeleteEvents[] = [
'eventId' => (int)$row['id'],
'nationId' => (int)($action[0][1] ?? 0),
];
} elseif ($actionName === 'InvaderEnding') {
$endingEventId = (int)$row['id'];
}
}
if (count($autoDeleteEvents) !== count($invaderNationIds) || $endingEventId <= 0) {
throw new \RuntimeException('InvaderLifecycle failed to create follow-up events');
}
$diplomacyRows = iterator_to_array($db->query(
'SELECT me, you, state, term FROM diplomacy WHERE (me IN %li AND you = %i) OR (me = %i AND you IN %li) ORDER BY me, you',
$invaderNationIds,
$existingNationId,
$existingNationId,
$invaderNationIds,
));
$diplomacyStates = array_values(array_unique(array_map(
static fn(array $row): string => "{$row['state']}:{$row['term']}",
$diplomacyRows,
)));
sort($diplomacyStates);
$generalCounts = array_map(
static fn(int $nationId): int => (int)DB::db()->queryFirstField(
'SELECT count(*) FROM general WHERE nation = %i',
$nationId,
),
$invaderNationIds,
);
$diplomacyCounts = array_map(
static fn(int $nationId): int => (int)DB::db()->queryFirstField(
'SELECT count(*) FROM diplomacy WHERE (me = %i AND you = %i) OR (me = %i AND you = %i)',
$nationId,
$existingNationId,
$existingNationId,
$nationId,
),
$invaderNationIds,
);
$gameStorage = KVStorage::getStorage($db, 'game_env');
$afterRaise = [
'createdNationCount' => count($invaderNationIds),
'createdGeneralCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM general WHERE no > %i',
$generalIdBeforeAction,
),
'followUpEventCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id > %i',
$eventIdBeforeAction,
),
'generalCountsPerNation' => array_values(array_unique($generalCounts)),
'diplomacyCountsPerNation' => array_values(array_unique($diplomacyCounts)),
'diplomacyStates' => $diplomacyStates,
'isunited' => (int)$gameStorage->isunited,
'blockChangeScout' => (bool)$gameStorage->block_change_scout,
];
$warResults = [];
foreach ($autoDeleteEvents as $autoDeleteEvent) {
$warResult = (new \sammo\Event\Action\AutoDeleteInvader($autoDeleteEvent['nationId']))->run([
'year' => $year,
'month' => $month + 1,
'startyear' => $startYear,
'currentEventID' => $autoDeleteEvent['eventId'],
]);
$warResults[] = $warResult[1] ?? null;
}
$atWar = [
'results' => array_values(array_unique($warResults)),
'autoDeleteEventCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id IN %li',
array_column($autoDeleteEvents, 'eventId'),
),
];
$db->update(
'diplomacy',
['state' => 2, 'term' => 0],
'me IN %li OR you IN %li',
$invaderNationIds,
$invaderNationIds,
);
$peaceResults = [];
foreach ($autoDeleteEvents as $autoDeleteEvent) {
$peaceResult = (new \sammo\Event\Action\AutoDeleteInvader($autoDeleteEvent['nationId']))->run([
'year' => $year,
'month' => $month + 2,
'startyear' => $startYear,
'currentEventID' => $autoDeleteEvent['eventId'],
]);
$peaceResults[] = $peaceResult[1] ?? null;
}
$atPeace = [
'results' => array_values(array_unique($peaceResults)),
'autoDeleteEventCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id IN %li',
array_column($autoDeleteEvents, 'eventId'),
),
'rulerWanderTurnCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM general_turn WHERE general_id IN %li AND action = %s',
$rulerIds,
'che_방랑',
),
'endingEventPresent' => (bool)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id = %i',
$endingEventId,
),
];
$invaderGeneralIds = array_map('intval', $db->queryFirstColumn(
'SELECT no FROM general WHERE nation IN %li',
$invaderNationIds,
));
if ($invaderGeneralIds !== []) {
$db->delete('general_turn', 'general_id IN %li', $invaderGeneralIds);
$db->delete('rank_data', 'general_id IN %li', $invaderGeneralIds);
$db->delete('troop', 'nation IN %li', $invaderNationIds);
$db->delete('general', 'no IN %li', $invaderGeneralIds);
}
$db->delete('nation_turn', 'nation_id IN %li', $invaderNationIds);
$db->delete('diplomacy', 'me IN %li OR you IN %li', $invaderNationIds, $invaderNationIds);
$db->delete('nation', 'nation IN %li', $invaderNationIds);
$db->update('city', ['nation' => $existingNationId], true);
$endingResult = (new \sammo\Event\Action\InvaderEnding())->run([
'year' => $year,
'month' => $month + 3,
'startyear' => $startYear,
'currentEventID' => $endingEventId,
]);
$gameStorage->resetCache();
$afterUserWin = [
'result' => $endingResult[1] ?? null,
'endingEventPresent' => (bool)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id = %i',
$endingEventId,
),
'isunited' => (int)$gameStorage->isunited,
'refreshLimit' => (int)$gameStorage->refreshLimit,
'logs' => array_values($db->queryFirstColumn(
'SELECT text FROM world_history WHERE id > %i AND nation_id = 0 AND year = %i AND month = %i ORDER BY id',
$worldHistoryIdBeforeAction,
$year,
$month + 3,
)),
];
return [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => 'InvaderLifecycle',
'phases' => [
'afterRaise' => $afterRaise,
'atWar' => $atWar,
'atPeace' => $atPeace,
'afterUserWin' => $afterUserWin,
],
];
}
/**
* Exercise NPC nation creation and the same-month troop leader follow-up.
*
* @param array<string, mixed> $environment
* @return array<string, mixed>
*/
function comparisonNpcNationLifecycleTrace(
array $environment,
int $generalIdBeforeAction,
int $nationIdBeforeAction,
int $worldHistoryIdBeforeAction,
): array {
$db = DB::db();
$actionEnvironment = [
'year' => $environment['year'],
'month' => $environment['month'],
'startyear' => $environment['startyear'],
'turnterm' => $environment['turnterm'],
'turntime' => $environment['turntime'],
'show_img_level' => $environment['show_img_level'] ?? 3,
'stored_icons' => $environment['stored_icons'] ?? [],
'icon_path' => $environment['icon_path'] ?? '.',
'fiction' => $environment['fiction'] ?? [0],
];
$gameStorage = KVStorage::getStorage($db, 'game_env');
$leaderCounterBefore = (int)($gameStorage->lastNPCTroopLeaderID ?? 0);
(new \sammo\Event\Action\RaiseNPCNation())->run($actionEnvironment);
$createdNationIds = array_map(
'intval',
$db->queryFirstColumn('SELECT nation FROM nation WHERE nation > %i ORDER BY nation', $nationIdBeforeAction),
);
if ($createdNationIds === []) {
throw new \RuntimeException('NpcNationLifecycle failed to create a nation');
}
$createdNationGeneralCounts = array_map(
static fn(int $nationId): int => (int)DB::db()->queryFirstField(
'SELECT count(*) FROM general WHERE nation = %i',
$nationId,
),
$createdNationIds,
);
$afterRaise = [
'createdNationCount' => count($createdNationIds),
'createdGeneralCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM general WHERE no > %i',
$generalIdBeforeAction,
),
'generalCountsPerCreatedNation' => array_values(array_unique($createdNationGeneralCounts)),
'historyLogs' => array_values($db->queryFirstColumn(
'SELECT text FROM world_history WHERE id > %i AND nation_id = 0 ORDER BY id',
$worldHistoryIdBeforeAction,
)),
];
(new \sammo\Event\Action\ProvideNPCTroopLeader())->run($actionEnvironment);
$leaderIds = array_map(
'intval',
$db->queryFirstColumn('SELECT no FROM general WHERE no > %i AND npc = 5 ORDER BY no', $generalIdBeforeAction),
);
if ($leaderIds === []) {
throw new \RuntimeException('NpcNationLifecycle failed to create troop leaders');
}
$leaderNationCounts = array_map(
static fn(int $nationId): int => (int)DB::db()->queryFirstField(
'SELECT count(*) FROM general WHERE nation = %i AND npc = 5',
$nationId,
),
array_map(
'intval',
$db->queryFirstColumn('SELECT nation FROM nation WHERE level > 0 ORDER BY nation'),
),
);
$gatherTurnCounts = array_map(
static fn(int $generalId): int => (int)DB::db()->queryFirstField(
'SELECT count(*) FROM general_turn WHERE general_id = %i AND action = %s',
$generalId,
'che_집합',
),
$leaderIds,
);
$gameStorage->resetCache();
$afterProvide = [
'createdLeaderCount' => count($leaderIds),
'leaderCountsPerActiveNation' => array_values(array_unique($leaderNationCounts)),
'troopCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM troop WHERE troop_leader IN %li',
$leaderIds,
),
'gatherTurnCounts' => array_values(array_unique($gatherTurnCounts)),
'leaderCounterDelta' => (int)$gameStorage->lastNPCTroopLeaderID - $leaderCounterBefore,
];
return [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => 'NpcNationLifecycle',
'phases' => [
'afterRaise' => $afterRaise,
'afterProvide' => $afterProvide,
],
];
}
/**
* Exercise nation betting from monthly open through real user bets and settlement.
*
* @param array<string, mixed> $request
* @param array<string, mixed> $environment
* @return array<string, mixed>
*/
function comparisonNationBettingLifecycleTrace(
array $request,
array $environment,
int $eventIdBeforeAction,
int $worldHistoryIdBeforeAction,
): array {
$db = DB::db();
$args = $request['args'] ?? [];
if (!is_array($args) || count($args) > 2) {
throw new \InvalidArgumentException('NationBettingLifecycle args must have at most two entries');
}
$bettorGeneralIds = comparisonIntegerList(
$request['bettorGeneralIds'] ?? [],
'bettorGeneralIds',
);
$bettorOwnerIds = comparisonIntegerList(
$request['bettorOwnerIds'] ?? [],
'bettorOwnerIds',
);
if (count($bettorGeneralIds) !== 2 || count($bettorOwnerIds) !== 2) {
throw new \InvalidArgumentException(
'NationBettingLifecycle requires exactly two bettorGeneralIds and bettorOwnerIds',
);
}
$year = $environment['year'];
$month = $environment['month'];
$startYear = $environment['startyear'];
(new \sammo\Event\Action\OpenNationBetting(...$args))->run([
'year' => $year,
'month' => $month,
'startyear' => $startYear,
]);
$gameStorage = KVStorage::getStorage($db, 'game_env');
$gameStorage->resetCache();
$bettingId = (int)$gameStorage->last_betting_id;
$betting = new Betting($bettingId);
$bettingInfo = $betting->getInfo();
$candidateNationIds = array_map(
static fn(\sammo\DTO\SelectItem $candidate): int => (int)($candidate->aux['nation'] ?? 0),
$bettingInfo->candidates,
);
if (count($candidateNationIds) !== 2) {
throw new \RuntimeException('NationBettingLifecycle requires exactly two candidates');
}
$finishEventCount = (int)$db->queryFirstField(
'SELECT count(*) FROM event WHERE id > %i AND action LIKE %s',
$eventIdBeforeAction,
"%FinishNationBetting%{$bettingId}%",
);
$afterOpen = [
'bettingId' => $bettingId,
'candidateNationIds' => $candidateNationIds,
'systemBonus' => (int)($db->queryFirstField(
'SELECT amount FROM ng_betting WHERE betting_id = %i AND general_id = 0',
$bettingId,
) ?? 0),
'finishEventCount' => $finishEventCount,
'historyLogs' => array_values($db->queryFirstColumn(
'SELECT text FROM world_history WHERE id > %i AND nation_id = 0 ORDER BY id',
$worldHistoryIdBeforeAction,
)),
];
$betting->bet($bettorGeneralIds[0], $bettorOwnerIds[0], [1], 100);
$betting->bet($bettorGeneralIds[1], $bettorOwnerIds[1], [0], 100);
$afterBets = [
'bets' => array_map(
static fn(array $row): array => [
'generalId' => (int)$row['general_id'],
'ownerId' => $row['user_id'] === null ? null : (int)$row['user_id'],
'selectionKey' => $row['betting_type'],
'amount' => (int)$row['amount'],
],
iterator_to_array($db->query(
'SELECT general_id, user_id, betting_type, amount FROM ng_betting WHERE betting_id = %i ORDER BY general_id',
$bettingId,
)),
),
'inheritancePrevious' => array_map(
static function (int $ownerId): int {
$value = KVStorage::getStorage(DB::db(), "inheritance_{$ownerId}")->getValue('previous');
return is_array($value) ? (int)($value[0] ?? 0) : 0;
},
$bettorOwnerIds,
),
'spentRank' => array_map(
static fn(int $generalId): int => (int)(DB::db()->queryFirstField(
'SELECT value FROM rank_data WHERE general_id = %i AND type = %s',
$generalId,
\sammo\Enums\RankColumn::inherit_point_spent_dynamic->value,
) ?? 0),
$bettorGeneralIds,
),
];
$db->update('nation', ['level' => 0], 'nation = %i', $candidateNationIds[1]);
$finishResult = (new \sammo\Event\Action\FinishNationBetting($bettingId))->run([
'year' => $year,
'month' => $month + 1,
'startyear' => $startYear,
]);
$settledInfo = (new Betting($bettingId))->getInfo();
$afterFinish = [
'result' => $finishResult[1] ?? null,
'finished' => $settledInfo->finished,
'winner' => $settledInfo->winner,
'inheritancePrevious' => array_map(
static function (int $ownerId): int {
$value = KVStorage::getStorage(DB::db(), "inheritance_{$ownerId}")->getValue('previous');
return is_array($value) ? (int)($value[0] ?? 0) : 0;
},
$bettorOwnerIds,
),
'earnedRank' => array_map(
static fn(int $generalId): int => (int)(DB::db()->queryFirstField(
'SELECT value FROM rank_data WHERE general_id = %i AND type = %s',
$generalId,
\sammo\Enums\RankColumn::inherit_point_earned_by_action->value,
) ?? 0),
$bettorGeneralIds,
),
'historyLogs' => array_values($db->queryFirstColumn(
'SELECT text FROM world_history WHERE id > %i AND nation_id = 0 ORDER BY id',
$worldHistoryIdBeforeAction,
)),
];
return [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => 'NationBettingLifecycle',
'phases' => [
'afterOpen' => $afterOpen,
'afterBets' => $afterBets,
'afterFinish' => $afterFinish,
],
];
}
/**
* Reproduce the player-visible handoff from a completed unification to the
* invader event and prove that the real turn engine can cross the next monthly
* boundary afterwards. The surrounding runner executes this only against its
* disposable MariaDB clone.
*
* @param array<string, mixed> $request
* @param array<string, mixed> $environment
* @return array<string, mixed>
*/
function comparisonUnificationInvaderResumeTrace(array $request, array $environment): array
{
$db = DB::db();
$gameStorage = KVStorage::getStorage($db, 'game_env');
$winnerGeneralId = $request['winnerGeneralId'] ?? null;
if (!is_int($winnerGeneralId) || $winnerGeneralId <= 0) {
throw new \InvalidArgumentException('UnificationInvaderResume requires a positive winnerGeneralId');
}
$winnerNationId = (int)($db->queryFirstField(
'SELECT nation FROM general WHERE no = %i',
$winnerGeneralId,
) ?? 0);
if ($winnerNationId <= 0) {
throw new \RuntimeException('winner general must belong to a nation');
}
$allCityCount = count(CityConst::all());
$winnerCityCount = (int)$db->queryFirstField(
'SELECT COUNT(*) FROM city WHERE nation = %i',
$winnerNationId,
);
if ($winnerCityCount !== $allCityCount) {
throw new \RuntimeException(
"winner must occupy every scenario city: winner={$winnerCityCount}, scenario={$allCityCount}",
);
}
$year = $environment['year'] ?? null;
$month = $environment['month'] ?? null;
$startYear = $environment['startyear'] ?? null;
if (!is_int($year) || !is_int($month) || !is_int($startYear) || $year - $startYear < 20) {
throw new \InvalidArgumentException('fixture must start at least 20 game years after startyear');
}
$unificationTick = Util::toInt($gameStorage->turntime);
$elapsedMonths = ($year - $startYear) * 12 + $month - 1;
$gameStorage->starttime = GameClock::addTicks(
cutTurn($unificationTick, Util::toInt($gameStorage->turnterm)),
-$elapsedMonths * GameClock::TICKS_PER_TURN,
);
$clock = GameClock::fromStorage($gameStorage);
$clock->persistTick($gameStorage, $unificationTick, GameClock::MODE_MANUAL);
$db->update('general', ['turntime' => $unificationTick], true);
$db->update('plock', ['plock' => 0], true);
$gameStorage->resetCache();
refreshNationStaticInfo();
$before = [
'year' => Util::toInt($gameStorage->year),
'month' => Util::toInt($gameStorage->month),
'isunited' => Util::toInt($gameStorage->isunited),
'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(),
'turntime' => Util::toInt($gameStorage->turntime),
'nationCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM nation WHERE level > 0'),
'winnerCityCount' => $winnerCityCount,
];
checkEmperior();
$gameStorage->resetCache();
$invaderMessages = [];
foreach ($db->query(
'SELECT id, mailbox, message FROM message WHERE mailbox = %i ORDER BY id',
$winnerGeneralId,
) as $row) {
$message = Json::decode($row['message']);
if (($message['option']['action'] ?? null) !== 'raiseInvader') {
continue;
}
$invaderMessages[] = [
'id' => (int)$row['id'],
'mailbox' => (int)$row['mailbox'],
'text' => (string)($message['text'] ?? ''),
'args' => $message['option']['args'] ?? null,
'used' => (bool)($message['option']['used'] ?? false),
];
}
if (count($invaderMessages) !== 3) {
throw new \RuntimeException('unification must create exactly three invader choices for the ruler');
}
$buttonSnapshot = [
'year' => Util::toInt($gameStorage->year),
'month' => Util::toInt($gameStorage->month),
'isunited' => Util::toInt($gameStorage->isunited),
'clockMode' => GameClock::fromStorage($gameStorage)->getMode(),
'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(),
'turntime' => Util::toInt($gameStorage->turntime),
'plock' => (int)$db->queryFirstField("SELECT plock FROM plock WHERE type = 'GAME' LIMIT 1"),
'messages' => $invaderMessages,
];
$stopAfterButton = $request['stopAfterButton'] ?? false;
if (!is_bool($stopAfterButton)) {
throw new \InvalidArgumentException('stopAfterButton must be a boolean');
}
if ($stopAfterButton) {
return [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => 'UnificationInvaderResume',
'phase' => 'button',
'before' => $before,
'buttonSnapshot' => $buttonSnapshot,
];
}
// Simulate an autoreset poll that recorded the original unification pause.
// A successful event start must replace this stale operational wall anchor.
$pausedAnchor = '2000-01-01 00:00:00.000000';
$gameStorage->autoreset_united_wall_anchor = $pausedAnchor;
$preSummonGeneralCopies = $request['preSummonGeneralCopies'] ?? 0;
if (!is_int($preSummonGeneralCopies) || $preSummonGeneralCopies < 0 || $preSummonGeneralCopies > 1000) {
throw new \InvalidArgumentException('preSummonGeneralCopies must be an integer between 0 and 1000');
}
if ($preSummonGeneralCopies > 0) {
$templateGeneral = $db->queryFirstRow('SELECT * FROM general WHERE no = %i', $winnerGeneralId);
if (!$templateGeneral) {
throw new \RuntimeException('winner general template is missing');
}
unset($templateGeneral['no']);
$templateGeneral['owner'] = 0;
$templateGeneral['owner_name'] = null;
$templateGeneral['npc'] = 2;
$templateGeneral['npc_org'] = 2;
$templateGeneral['officer_level'] = 0;
for ($copyIndex = 1; $copyIndex <= $preSummonGeneralCopies; $copyIndex++) {
$templateGeneral['name'] = "ⓧ재현장수{$copyIndex}";
$db->insert('general', $templateGeneral);
}
}
$invaderChoiceIndex = $request['invaderChoiceIndex'] ?? array_key_last($invaderMessages);
if (!is_int($invaderChoiceIndex) || !array_key_exists($invaderChoiceIndex, $invaderMessages)) {
throw new \InvalidArgumentException('invaderChoiceIndex must select one of the three invader messages');
}
$selectedMessageId = $invaderMessages[$invaderChoiceIndex]['id'];
$message = Message::getMessageByID($selectedMessageId);
if (!$message instanceof RaiseInvaderMessage) {
throw new \RuntimeException('selected invader message did not hydrate as RaiseInvaderMessage');
}
$reason = '';
$summonStartedAt = hrtime(true);
$decisionResult = $message->agreeMessage($winnerGeneralId, $reason);
$summonElapsedMs = (hrtime(true) - $summonStartedAt) / 1_000_000;
$gameStorage->resetCache();
$afterSummon = [
'result' => $decisionResult,
'reason' => $reason,
'isunited' => Util::toInt($gameStorage->isunited),
'clockMode' => GameClock::fromStorage($gameStorage)->getMode(),
'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(),
'turntime' => Util::toInt($gameStorage->turntime),
'turnterm' => Util::toInt($gameStorage->turnterm),
'plock' => (int)$db->queryFirstField("SELECT plock FROM plock WHERE type = 'GAME' LIMIT 1"),
'nationCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM nation WHERE level > 0'),
'invaderNationCount' => (int)$db->queryFirstField("SELECT COUNT(*) FROM nation WHERE name LIKE 'ⓞ%족'"),
'eventCount' => (int)$db->queryFirstField(
"SELECT COUNT(*) FROM event WHERE action LIKE '%Invader%'",
),
'selectedChoiceIndex' => $invaderChoiceIndex,
'preSummonGeneralCopies' => $preSummonGeneralCopies,
'summonElapsedMs' => round($summonElapsedMs, 3),
'generalCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM general'),
'minimumInvaderGeneralCount' => (int)$db->queryFirstField(
"SELECT MIN(general_count) FROM (SELECT COUNT(*) AS general_count FROM general g JOIN nation n ON n.nation = g.nation WHERE n.name LIKE 'ⓞ%족' GROUP BY g.nation) invader_counts",
),
'maximumInvaderGeneralCount' => (int)$db->queryFirstField(
"SELECT MAX(general_count) FROM (SELECT COUNT(*) AS general_count FROM general g JOIN nation n ON n.nation = g.nation WHERE n.name LIKE 'ⓞ%족' GROUP BY g.nation) invader_counts",
),
'autoresetAnchor' => $gameStorage->autoreset_united_wall_anchor,
'autoresetAnchorRefreshed' => $gameStorage->autoreset_united_wall_anchor !== $pausedAnchor,
];
$maxSummonElapsedMs = $request['maxSummonElapsedMs'] ?? null;
if ($maxSummonElapsedMs !== null && (!is_int($maxSummonElapsedMs) || $maxSummonElapsedMs <= 0)) {
throw new \InvalidArgumentException('maxSummonElapsedMs must be a positive integer');
}
if (
$decisionResult !== RaiseInvaderMessage::ACCEPTED
|| $afterSummon['isunited'] !== 1
|| $afterSummon['plock'] !== 0
|| $afterSummon['invaderNationCount'] !== 7
|| $afterSummon['autoresetAnchorRefreshed'] !== true
|| ($maxSummonElapsedMs !== null && $summonElapsedMs >= $maxSummonElapsedMs)
) {
throw new \RuntimeException('invader choice did not start an unlocked, watchdog-active event game');
}
$stopAfterSummon = $request['stopAfterSummon'] ?? false;
if (!is_bool($stopAfterSummon)) {
throw new \InvalidArgumentException('stopAfterSummon must be a boolean');
}
if ($stopAfterSummon) {
return [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => 'UnificationInvaderResume',
'phase' => 'summon',
'before' => $before,
'buttonSnapshot' => $buttonSnapshot,
'afterSummon' => $afterSummon,
];
}
$resumeBoundary = addTurn(
cutTurn(Util::toInt($gameStorage->turntime), Util::toInt($gameStorage->turnterm)),
Util::toInt($gameStorage->turnterm),
);
GameClock::fromStorage($gameStorage)->persistTick(
$gameStorage,
GameClock::addTicks($resumeBoundary, 1),
GameClock::MODE_MANUAL,
);
$gameStorage->resetCache();
// Likewise, active event progress must move the inactivity anchor. Leaving
// this stale makes j_autoreset close a healthy invader game on the original
// unification timeline.
$stalledAnchor = '2000-01-02 00:00:00.000000';
$gameStorage->autoreset_united_wall_anchor = $stalledAnchor;
$engineCalls = 0;
$executedCalls = 0;
$lockedCalls = 0;
while ($engineCalls < 20 && Util::toInt($gameStorage->turntime) < $resumeBoundary) {
$executed = false;
$locked = false;
TurnExecutionHelper::executeAllCommand($executed, $locked);
$engineCalls++;
if ($executed) {
$executedCalls++;
}
if ($locked) {
$lockedCalls++;
}
$gameStorage->resetCache();
}
$afterResume = [
'year' => Util::toInt($gameStorage->year),
'month' => Util::toInt($gameStorage->month),
'isunited' => Util::toInt($gameStorage->isunited),
'clockMode' => GameClock::fromStorage($gameStorage)->getMode(),
'clockTick' => GameClock::fromStorage($gameStorage)->nowTick(),
'turntime' => Util::toInt($gameStorage->turntime),
'resumeBoundary' => $resumeBoundary,
'engineCalls' => $engineCalls,
'executedCalls' => $executedCalls,
'lockedCalls' => $lockedCalls,
'plock' => (int)$db->queryFirstField("SELECT plock FROM plock WHERE type = 'GAME' LIMIT 1"),
'worldHistoryCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM world_history'),
'generalRecordCount' => (int)$db->queryFirstField('SELECT COUNT(*) FROM general_record'),
'autoresetAnchor' => $gameStorage->autoreset_united_wall_anchor,
'autoresetAnchorRefreshed' => $gameStorage->autoreset_united_wall_anchor !== $stalledAnchor,
];
$expectedYear = $month === 12 ? $year + 1 : $year;
$expectedMonth = $month === 12 ? 1 : $month + 1;
if (
$afterResume['turntime'] < $resumeBoundary
|| $afterResume['year'] !== $expectedYear
|| $afterResume['month'] !== $expectedMonth
|| $afterResume['isunited'] !== 1
|| $afterResume['lockedCalls'] !== 0
|| $afterResume['plock'] !== 0
|| $afterResume['autoresetAnchorRefreshed'] !== true
) {
throw new \RuntimeException('invader event game did not advance through the next monthly boundary');
}
return [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => 'UnificationInvaderResume',
'before' => $before,
'buttonSnapshot' => $buttonSnapshot,
'afterSummon' => $afterSummon,
'afterResume' => $afterResume,
];
}
function comparisonMonthlyEventTraceMain(): void
{
try {
$input = stream_get_contents(STDIN);
$request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($request)) {
throw new \InvalidArgumentException('request must be an object');
}
$actionName = $request['action'] ?? null;
$supportedActions = [
'UpdateCitySupply',
'UpdateNationLevel',
'RaiseDisaster',
'ProcessSemiAnnual',
'ProcessWarIncome',
'CreateAdminNPC',
'CreateManyNPC',
'RegNPC',
'RegNeutralNPC',
'RaiseNPCNation',
'NpcNationLifecycle',
'RaiseInvader',
'InvaderLifecycle',
'UnificationInvaderResume',
'AutoDeleteInvader',
'InvaderEnding',
'ChangeCity',
'ProvideNPCTroopLeader',
'NationBettingLifecycle',
'OpenNationBetting',
'FinishNationBetting',
'BlockScoutAction',
'UnblockScoutAction',
'AssignGeneralSpeciality',
'AddGlobalBetray',
'LostUniqueItem',
'MergeInheritPointRank',
'ProcessIncome',
'NoticeToHistoryLog',
'NewYear',
'ResetOfficerLock',
'PreUpdateMonthly',
'PostUpdateMonthly',
'MonthlyBoundary',
];
if (!in_array($actionName, $supportedActions, true)) {
throw new \InvalidArgumentException('unsupported monthly action');
}
$setup = $request['setup'] ?? [];
if (!is_array($setup)) {
throw new \InvalidArgumentException('setup must be an object');
}
foreach (['city', 'nation', 'general'] as $table) {
$rows = $setup[$table] ?? [];
if (!is_array($rows)) {
throw new \InvalidArgumentException("setup.{$table} must be an array");
}
foreach ($rows as $row) {
if (!is_array($row) || !is_int($row['id'] ?? null) || !is_array($row['values'] ?? null)) {
throw new \InvalidArgumentException("invalid setup.{$table} row");
}
comparisonMonthlyPatch($table, $row['id'], $row['values']);
}
}
if (($setup['resetCities'] ?? false) === true) {
DB::db()->query('UPDATE city SET nation = 0, level = 1');
foreach ($setup['city'] ?? [] as $row) {
comparisonMonthlyPatch('city', $row['id'], $row['values']);
}
}
if (($setup['deleteOtherCities'] ?? false) === true) {
$keepCityIds = comparisonIntegerList($setup['keepCityIds'] ?? [], 'keepCityIds');
if ($keepCityIds === []) {
throw new \InvalidArgumentException('deleteOtherCities requires keepCityIds');
}
DB::db()->query('DELETE FROM city WHERE city NOT IN %li', $keepCityIds);
}
if (($setup['resetGenerals'] ?? false) === true) {
DB::db()->query(
'UPDATE general SET nation = 0, officer_level = 1, npc = 2, killturn = 0, horse = %s, weapon = %s, book = %s, item = %s',
'None',
'None',
'None',
'None',
);
foreach ($setup['general'] ?? [] as $row) {
comparisonMonthlyPatch('general', $row['id'], $row['values']);
}
}
if (($setup['deleteOtherGenerals'] ?? false) === true) {
$keepGeneralIds = comparisonIntegerList($setup['keepGeneralIds'] ?? [], 'keepGeneralIds');
if (!$keepGeneralIds) {
throw new \InvalidArgumentException('deleteOtherGenerals requires keepGeneralIds');
}
DB::db()->query('DELETE FROM general_turn WHERE general_id NOT IN %li', $keepGeneralIds);
DB::db()->query('DELETE FROM rank_data WHERE general_id NOT IN %li', $keepGeneralIds);
DB::db()->query('DELETE FROM general WHERE no NOT IN %li', $keepGeneralIds);
}
if (($setup['resetNations'] ?? false) === true) {
DB::db()->query('UPDATE nation SET level = 0, gennum = 0, tech = 0');
foreach ($setup['nation'] ?? [] as $row) {
comparisonMonthlyPatch('nation', $row['id'], $row['values']);
}
}
if (($setup['deleteOtherNations'] ?? false) === true) {
$keepNationIds = comparisonIntegerList($setup['keepNationIds'] ?? [], 'keepNationIds');
if (!$keepNationIds) {
throw new \InvalidArgumentException('deleteOtherNations requires keepNationIds');
}
DB::db()->query(
'DELETE FROM diplomacy WHERE me NOT IN %li OR you NOT IN %li',
$keepNationIds,
$keepNationIds,
);
DB::db()->query('DELETE FROM nation_turn WHERE nation_id NOT IN %li', $keepNationIds);
DB::db()->query('DELETE FROM nation WHERE nation NOT IN %li', $keepNationIds);
}
if (($setup['resetUniqueOccupancy'] ?? false) === true) {
DB::db()->delete('ng_auction', '`type` = %s AND finished = 0', 'uniqueItem');
DB::db()->query('DELETE FROM storage WHERE namespace LIKE %s', 'ut\\_%');
}
if (($setup['clearNeutralAuctions'] ?? false) === true) {
DB::db()->delete(
'ng_auction',
'`type` IN %ls AND host_general_id = 0',
['buyRice', 'sellRice'],
);
}
foreach (comparisonIntegerList($setup['clearNationTurnIds'] ?? [], 'clearNationTurnIds') as $nationId) {
DB::db()->delete('nation_turn', 'nation_id = %i', $nationId);
}
foreach (comparisonIntegerList($setup['clearInheritanceOwnerIds'] ?? [], 'clearInheritanceOwnerIds') as $ownerId) {
DB::db()->delete('storage', 'namespace = %s', "inheritance_{$ownerId}");
}
if (($setup['clearBetting'] ?? false) === true) {
DB::db()->query('DELETE FROM ng_betting');
DB::db()->query('DELETE FROM storage WHERE namespace = %s', 'betting');
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
$gameStorage->setValue('last_betting_id', 0);
}
foreach ($setup['bettingInfo'] ?? [] as $rawBettingInfo) {
if (!is_array($rawBettingInfo) || !is_int($rawBettingInfo['id'] ?? null)) {
throw new \InvalidArgumentException('invalid setup.bettingInfo row');
}
KVStorage::getStorage(DB::db(), 'betting')->setValue(
"id_{$rawBettingInfo['id']}",
$rawBettingInfo,
);
}
foreach ($setup['bet'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['betting_id'] ?? null)
|| !is_int($row['general_id'] ?? null)
|| !is_string($row['betting_type'] ?? null)
|| !is_int($row['amount'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.bet row');
}
DB::db()->insert('ng_betting', [
'betting_id' => $row['betting_id'],
'general_id' => $row['general_id'],
'user_id' => $row['user_id'] ?? null,
'betting_type' => $row['betting_type'],
'amount' => $row['amount'],
]);
}
foreach ($setup['inheritancePrevious'] ?? [] as $ownerId => $value) {
if (!is_numeric($ownerId) || !is_int($value)) {
throw new \InvalidArgumentException('invalid setup.inheritancePrevious entry');
}
KVStorage::getStorage(DB::db(), "inheritance_{$ownerId}")->setValue('previous', [$value, null]);
}
foreach ($setup['inheritancePoints'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['ownerId'] ?? null)
|| !is_string($row['key'] ?? null)
|| !is_numeric($row['value'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.inheritancePoints row');
}
KVStorage::getStorage(DB::db(), "inheritance_{$row['ownerId']}")->setValue(
$row['key'],
[$row['value'], null],
);
}
foreach ($setup['rankData'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['generalId'] ?? null)
|| !is_string($row['type'] ?? null)
|| !is_int($row['value'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.rankData row');
}
$nationId = (int)(DB::db()->queryFirstField(
'SELECT nation FROM general WHERE no = %i',
$row['generalId'],
) ?? 0);
DB::db()->insertUpdate('rank_data', [
'general_id' => $row['generalId'],
'nation_id' => $nationId,
'type' => $row['type'],
'value' => $row['value'],
], [
'nation_id' => $nationId,
'value' => $row['value'],
]);
}
foreach ($setup['generalAccessLog'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['generalId'] ?? null)
|| !is_int($row['refreshScoreTotal'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.generalAccessLog row');
}
DB::db()->insertUpdate('general_access_log', [
'general_id' => $row['generalId'],
'refresh_score_total' => $row['refreshScoreTotal'],
], [
'refresh_score_total' => $row['refreshScoreTotal'],
]);
}
foreach ($setup['nationEnvironment'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['nationId'] ?? null)
|| !is_string($row['key'] ?? null)
|| !array_key_exists('value', $row)
) {
throw new \InvalidArgumentException('invalid setup.nationEnvironment row');
}
KVStorage::getStorage(DB::db(), $row['nationId'], 'nation_env')->setValue(
$row['key'],
$row['value'],
);
}
if (($setup['resetDiplomacy'] ?? false) === true) {
DB::db()->query('DELETE FROM diplomacy');
}
foreach ($setup['diplomacy'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['fromNationId'] ?? null)
|| !is_int($row['toNationId'] ?? null)
|| !is_int($row['state'] ?? null)
|| !is_int($row['term'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.diplomacy row');
}
$diplomacyPatch = [
'me' => $row['fromNationId'],
'you' => $row['toNationId'],
'state' => $row['state'],
'term' => $row['term'],
'dead' => $row['dead'] ?? 0,
];
DB::db()->insertUpdate('diplomacy', $diplomacyPatch, $diplomacyPatch);
}
if (($setup['resetEvents'] ?? false) === true) {
DB::db()->query('DELETE FROM event');
}
foreach ($setup['event'] ?? [] as $row) {
if (!is_array($row) || !is_int($row['id'] ?? null) || !is_array($row['action'] ?? null)) {
throw new \InvalidArgumentException('invalid setup.event row');
}
$eventPatch = [
'id' => $row['id'],
'target' => $row['target'] ?? 'month',
'priority' => $row['priority'] ?? 1000,
'condition' => Json::encode($row['condition'] ?? true),
'action' => Json::encode($row['action']),
];
DB::db()->insertUpdate('event', $eventPatch, $eventPatch);
}
$gameEnvironmentPatch = $setup['gameEnvironment'] ?? [];
if (!is_array($gameEnvironmentPatch)) {
throw new \InvalidArgumentException('setup.gameEnvironment must be an object');
}
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
$worldPatch = $setup['world'] ?? [];
if (!is_array($worldPatch)) {
throw new \InvalidArgumentException('setup.world must be an object');
}
if (array_key_exists('hiddenSeed', $worldPatch)) {
if (!is_string($worldPatch['hiddenSeed']) || $worldPatch['hiddenSeed'] === '') {
throw new \InvalidArgumentException('setup.world.hiddenSeed must be a non-empty string');
}
UniqueConst::$hiddenSeed = $worldPatch['hiddenSeed'];
}
foreach ([
'isunited',
'refreshLimit',
'block_change_scout',
'lastNPCTroopLeaderID',
'tournament',
'tnmt_trig',
'tnmt_pattern',
'prev_winner',
] as $key) {
if (array_key_exists($key, $gameEnvironmentPatch)) {
$gameStorage->setValue($key, $gameEnvironmentPatch[$key]);
}
}
if (($setup['setAllCitiesNation'] ?? null) !== null) {
if (!is_int($setup['setAllCitiesNation'])) {
throw new \InvalidArgumentException('setup.setAllCitiesNation must be an integer');
}
DB::db()->update('city', ['nation' => $setup['setAllCitiesNation']], true);
}
$db = DB::db();
$generalIdBeforeAction =
(int)($db->queryFirstField('SELECT COALESCE(MAX(no), 0) FROM general') ?? 0);
$nationIdBeforeAction =
(int)($db->queryFirstField('SELECT COALESCE(MAX(nation), 0) FROM nation') ?? 0);
$eventIdBeforeAction =
(int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM event') ?? 0);
$auctionIdBeforeAction =
(int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM ng_auction') ?? 0);
$snapshotRequest = ['observe' => $request['observe'] ?? []];
$snapshotRequest['observe']['logAfterId'] =
(int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM general_record') ?? 0);
$worldHistoryAfterId =
(int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM world_history') ?? 0);
$before = comparisonTurnStateSnapshot($snapshotRequest);
$beforeDetails = comparisonMonthlyDetails($snapshotRequest['observe'], $worldHistoryAfterId);
$environment = $request['environment'] ?? [];
if (!is_array($environment)) {
throw new \InvalidArgumentException('environment must be an object');
}
foreach (['starttime', 'turntime'] as $timeKey) {
if (array_key_exists($timeKey, $environment)) {
$environment[$timeKey] = comparisonGameTick(
$environment[$timeKey],
"environment.{$timeKey}",
);
}
}
$year = $environment['year'] ?? null;
$month = $environment['month'] ?? null;
$startYear = $environment['startyear'] ?? null;
if (!is_int($year) || !is_int($month) || !is_int($startYear)) {
throw new \InvalidArgumentException('environment year, month, and startyear must be integers');
}
if (($setup['syncEnvironment'] ?? false) === true) {
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
$environmentValues = [
'year' => $year,
'month' => $month,
'startyear' => $startYear,
'killturn' => $environment['killturn'] ?? $gameStorage->killturn,
'turnterm' => $environment['turnterm'] ?? $gameStorage->turnterm,
];
if (isset($environment['starttime'])) {
$environmentValues['starttime'] = $environment['starttime'];
}
foreach ($environmentValues as $key => $value) {
$gameStorage->setValue($key, $value);
}
if ($actionName === 'MonthlyBoundary') {
$before = comparisonTurnStateSnapshot($snapshotRequest);
$beforeDetails = comparisonMonthlyDetails($snapshotRequest['observe'], $worldHistoryAfterId);
}
}
if ($actionName === 'InvaderLifecycle') {
foreach (['turnterm', 'turntime'] as $requiredEnvironmentKey) {
if (!array_key_exists($requiredEnvironmentKey, $environment)) {
throw new \InvalidArgumentException(
"InvaderLifecycle requires environment.{$requiredEnvironmentKey}",
);
}
}
echo json_encode(
comparisonInvaderLifecycleTrace(
$request,
$environment,
$generalIdBeforeAction,
$nationIdBeforeAction,
$eventIdBeforeAction,
$worldHistoryAfterId,
),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
return;
}
if ($actionName === 'UnificationInvaderResume') {
echo json_encode(
comparisonUnificationInvaderResumeTrace($request, $environment),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
);
return;
}
if ($actionName === 'NpcNationLifecycle') {
foreach (['turnterm', 'turntime'] as $requiredEnvironmentKey) {
if (!array_key_exists($requiredEnvironmentKey, $environment)) {
throw new \InvalidArgumentException(
"NpcNationLifecycle requires environment.{$requiredEnvironmentKey}",
);
}
}
echo json_encode(
comparisonNpcNationLifecycleTrace(
$environment,
$generalIdBeforeAction,
$nationIdBeforeAction,
$worldHistoryAfterId,
),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
return;
}
if ($actionName === 'NationBettingLifecycle') {
echo json_encode(
comparisonNationBettingLifecycleTrace(
$request,
$environment,
$eventIdBeforeAction,
$worldHistoryAfterId,
),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
return;
}
$actionClass = "\\sammo\\Event\\Action\\{$actionName}";
if (in_array($actionName, ['PreUpdateMonthly', 'PostUpdateMonthly', 'MonthlyBoundary'], true)) {
$action = null;
} elseif ($actionName === 'ProcessSemiAnnual') {
$resource = $request['resource'] ?? null;
if (!is_string($resource)) {
throw new \InvalidArgumentException('ProcessSemiAnnual requires resource');
}
$action = new $actionClass($resource);
} elseif (in_array($actionName, ['CreateManyNPC', 'RegNPC', 'RegNeutralNPC', 'RaiseInvader', 'AutoDeleteInvader', 'ChangeCity', 'OpenNationBetting', 'FinishNationBetting', 'BlockScoutAction', 'UnblockScoutAction', 'AddGlobalBetray', 'LostUniqueItem', 'ProcessIncome', 'NoticeToHistoryLog'], true)) {
$args = $request['args'] ?? [];
if (!is_array($args)) {
throw new \InvalidArgumentException("{$actionName} args must be an array");
}
if ($actionName === 'CreateManyNPC' && count($args) > 2) {
throw new \InvalidArgumentException('CreateManyNPC args must have at most two entries');
}
if ($actionName === 'RaiseInvader' && count($args) > 4) {
throw new \InvalidArgumentException('RaiseInvader args must have at most four entries');
}
if ($actionName === 'ChangeCity' && count($args) !== 2) {
throw new \InvalidArgumentException('ChangeCity args must have exactly two entries');
}
if ($actionName === 'OpenNationBetting' && count($args) > 2) {
throw new \InvalidArgumentException('OpenNationBetting args must have at most two entries');
}
if ($actionName === 'FinishNationBetting' && count($args) !== 1) {
throw new \InvalidArgumentException('FinishNationBetting args must have exactly one entry');
}
if (in_array($actionName, ['BlockScoutAction', 'UnblockScoutAction'], true) && count($args) > 1) {
throw new \InvalidArgumentException("{$actionName} args must have at most one entry");
}
if ($actionName === 'AddGlobalBetray' && count($args) > 2) {
throw new \InvalidArgumentException('AddGlobalBetray args must have at most two entries');
}
if ($actionName === 'LostUniqueItem' && count($args) > 1) {
throw new \InvalidArgumentException('LostUniqueItem args must have at most one entry');
}
if ($actionName === 'ProcessIncome' && count($args) !== 1) {
throw new \InvalidArgumentException('ProcessIncome args must have exactly one entry');
}
if ($actionName === 'NoticeToHistoryLog' && (count($args) < 1 || count($args) > 2)) {
throw new \InvalidArgumentException('NoticeToHistoryLog args must have one or two entries');
}
$action = new $actionClass(...$args);
} else {
$action = new $actionClass();
}
$actionEnvironment = [
'year' => $year,
'month' => $month,
'startyear' => $startYear,
];
if (isset($environment['currentEventID'])) {
if (!is_int($environment['currentEventID'])) {
throw new \InvalidArgumentException('currentEventID must be an integer');
}
$actionEnvironment['currentEventID'] = $environment['currentEventID'];
}
if ($actionName === 'UpdateNationLevel') {
$killturn = $environment['killturn'] ?? null;
$turnterm = $environment['turnterm'] ?? null;
if (!is_int($killturn) || !is_int($turnterm) || $turnterm <= 0) {
throw new \InvalidArgumentException('UpdateNationLevel requires integer killturn and positive turnterm');
}
$actionEnvironment['killturn'] = $killturn;
$actionEnvironment['turnterm'] = $turnterm;
}
if (in_array($actionName, ['CreateManyNPC', 'RegNPC', 'RegNeutralNPC', 'RaiseNPCNation', 'RaiseInvader', 'ProvideNPCTroopLeader'], true)) {
$turnterm = $environment['turnterm'] ?? null;
$turntime = $environment['turntime'] ?? null;
$showImgLevel = $environment['show_img_level'] ?? 3;
$fiction = $environment['fiction'] ?? [0];
if (
!is_int($turnterm)
|| $turnterm <= 0
|| !is_int($turntime)
|| !is_int($showImgLevel)
|| !is_array($fiction)
) {
throw new \InvalidArgumentException(
"{$actionName} requires positive turnterm, game-tick turntime, integer show_img_level, and array fiction",
);
}
$actionEnvironment += [
'turnterm' => $turnterm,
'turntime' => $turntime,
'show_img_level' => $showImgLevel,
'stored_icons' => $environment['stored_icons'] ?? [],
'icon_path' => $environment['icon_path'] ?? '.',
'fiction' => $fiction,
];
}
if ($actionName === 'PreUpdateMonthly') {
if (!preUpdateMonthly()) {
throw new \RuntimeException('preUpdateMonthly failed');
}
} elseif ($actionName === 'PostUpdateMonthly') {
$seedYear = $environment['seedYear'] ?? null;
$seedMonth = $environment['seedMonth'] ?? null;
if (!is_int($seedYear) || !is_int($seedMonth)) {
throw new \InvalidArgumentException('PostUpdateMonthly requires seedYear and seedMonth');
}
postUpdateMonthly(new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
UniqueConst::$hiddenSeed,
'monthly',
$seedYear,
$seedMonth,
))));
} elseif ($actionName === 'MonthlyBoundary') {
$turntime = $environment['turntime'] ?? null;
if (!is_int($turntime)) {
throw new \InvalidArgumentException('MonthlyBoundary requires turntime');
}
$gameStorage = KVStorage::getStorage($db, 'game_env');
$seedYear = (int)$gameStorage->year;
$seedMonth = (int)$gameStorage->month;
TurnExecutionHelper::runEventHandler($db, $gameStorage, Enums\EventTarget::PreMonth);
if (!preUpdateMonthly()) {
throw new \RuntimeException('preUpdateMonthly failed');
}
turnDate($turntime);
$gameStorage->resetCache();
if ((int)$gameStorage->month === 1) {
checkStatistic();
}
TurnExecutionHelper::runEventHandler($db, $gameStorage, Enums\EventTarget::Month);
postUpdateMonthly(new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
UniqueConst::$hiddenSeed,
'monthly',
$seedYear,
$seedMonth,
))));
} else {
$action->run($actionEnvironment);
}
unset($action);
gc_collect_cycles();
$after = comparisonTurnStateSnapshot($snapshotRequest);
$afterDetails = comparisonMonthlyDetails($snapshotRequest['observe'], $worldHistoryAfterId);
$createdGenerals = [];
if (in_array($actionName, ['CreateManyNPC', 'RegNPC', 'RegNeutralNPC', 'RaiseNPCNation', 'RaiseInvader', 'ProvideNPCTroopLeader'], true)) {
foreach ($db->query('SELECT * FROM general WHERE no > %i ORDER BY no', $generalIdBeforeAction) as $row) {
$generalId = (int)$row['no'];
$turnRows = $db->query(
'SELECT turn_idx, action, arg FROM general_turn WHERE general_id = %i ORDER BY turn_idx',
$generalId,
);
$createdGenerals[] = comparisonPickRow(
$row,
[
'id' => 'no',
'name' => 'name',
'nationId' => 'nation',
'cityId' => 'city',
'leadership' => 'leadership',
'strength' => 'strength',
'intelligence' => 'intel',
'experience' => 'experience',
'dedication' => 'dedication',
'officerLevel' => 'officer_level',
'gold' => 'gold',
'rice' => 'rice',
'crew' => 'crew',
'crewTypeId' => 'crewtype',
'train' => 'train',
'atmos' => 'atmos',
'turnTime' => 'turntime',
'killturn' => 'killturn',
'age' => 'age',
'npcState' => 'npc',
'npcOriginalState' => 'npc_org',
'affinity' => 'affinity',
'personality' => 'personal',
'specialDomestic' => 'special',
'specialWar' => 'special2',
'specAge' => 'specage',
'specAge2' => 'specage2',
'bornYear' => 'bornyear',
'deadYear' => 'deadyear',
'picture' => 'picture',
'dex1' => 'dex1',
'dex2' => 'dex2',
'dex3' => 'dex3',
'dex4' => 'dex4',
'dex5' => 'dex5',
],
) + [
'turnCount' => count($turnRows),
'turnActions' => array_values(array_unique(array_column($turnRows, 'action'))),
'rankCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM rank_data WHERE general_id = %i',
$generalId,
),
'nonZeroRankCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM rank_data WHERE general_id = %i AND value != 0',
$generalId,
),
'troop' => $db->queryFirstRow(
'SELECT troop_leader AS id, nation AS nationId, name FROM troop WHERE troop_leader = %i',
$generalId,
) ?: null,
];
}
}
$createdNations = [];
if (in_array($actionName, ['RaiseNPCNation', 'RaiseInvader'], true)) {
foreach ($db->query('SELECT * FROM nation WHERE nation > %i ORDER BY nation', $nationIdBeforeAction) as $row) {
$nationId = (int)$row['nation'];
$createdNations[] = comparisonPickRow(
$row,
[
'id' => 'nation',
'name' => 'name',
'color' => 'color',
'capitalCityId' => 'capital',
'generalCount' => 'gennum',
'gold' => 'gold',
'rice' => 'rice',
'tech' => 'tech',
'level' => 'level',
'typeCode' => 'type',
'bill' => 'bill',
'rate' => 'rate',
'scout' => 'scout',
'war' => 'war',
'strategicCommandLimit' => 'strategic_cmd_limit',
'surrenderLimit' => 'surlimit',
'aux' => 'aux',
],
['aux'],
) + [
'nationTurnCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM nation_turn WHERE nation_id = %i',
$nationId,
),
'diplomacyCount' => (int)$db->queryFirstField(
'SELECT count(*) FROM diplomacy WHERE me = %i OR you = %i',
$nationId,
$nationId,
),
];
}
}
$response = [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => $actionName,
'before' => $before,
'beforeDetails' => $beforeDetails,
'after' => $after,
'afterDetails' => $afterDetails,
];
if (in_array($actionName, ['CreateManyNPC', 'RegNPC', 'RegNeutralNPC'], true)) {
$response['createdGenerals'] = $createdGenerals;
}
if (in_array($actionName, ['RaiseNPCNation', 'RaiseInvader'], true)) {
$response['createdGenerals'] = $createdGenerals;
$response['createdNations'] = $createdNations;
}
if ($actionName === 'ProvideNPCTroopLeader') {
$response['createdGenerals'] = $createdGenerals;
$response['gameEnvironment'] = KVStorage::getStorage(DB::db(), 'game_env')->getValues([
'lastNPCTroopLeaderID',
]);
}
if ($actionName === 'PreUpdateMonthly') {
$response['gameEnvironment'] = KVStorage::getStorage(DB::db(), 'game_env')->getValues([
'develcost',
]);
}
if ($actionName === 'PostUpdateMonthly') {
$response['gameEnvironment'] = KVStorage::getStorage(DB::db(), 'game_env')->getValues([
'tournament',
'tnmt_trig',
'tnmt_pattern',
'tnmt_type',
'tnmt_auto',
'phase',
]);
$response['createdNeutralAuctions'] = array_map(
static fn(array $row): array => [
'id' => (int)$row['id'],
'type' => $row['type'],
'target' => $row['target'],
'detail' => Json::decode($row['detail']),
],
iterator_to_array(
$db->query(
'SELECT id, `type`, target, detail FROM ng_auction WHERE id > %i AND host_general_id = 0 ORDER BY id',
$auctionIdBeforeAction,
),
),
);
}
if (in_array($actionName, ['OpenNationBetting', 'FinishNationBetting'], true)) {
$bettingStorage = KVStorage::getStorage(DB::db(), 'betting');
$response['betting'] = $bettingStorage->getAll();
$response['bets'] = iterator_to_array(
$db->query(
'SELECT betting_id AS bettingId, general_id AS generalId, user_id AS userId, betting_type AS selectionKey, amount FROM ng_betting ORDER BY id',
),
);
$response['createdEvents'] = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'id',
'targetCode' => 'target',
'priority' => 'priority',
'condition' => 'condition',
'action' => 'action',
],
['condition', 'action'],
),
iterator_to_array(
$db->query(
'SELECT id, target, priority, `condition`, action FROM event WHERE id > %i ORDER BY id',
$eventIdBeforeAction,
),
),
);
$response['gameEnvironment'] = KVStorage::getStorage(DB::db(), 'game_env')->getValues([
'last_betting_id',
]);
$response['inheritancePrevious'] = [];
foreach (comparisonIntegerList($request['observe']['ownerIds'] ?? [], 'ownerIds') as $ownerId) {
$value = KVStorage::getStorage(DB::db(), "inheritance_{$ownerId}")->getValue('previous');
$response['inheritancePrevious'][(string)$ownerId] = is_array($value) ? ($value[0] ?? 0) : 0;
}
}
if (in_array($actionName, ['BlockScoutAction', 'UnblockScoutAction'], true)) {
$response['gameEnvironment'] = KVStorage::getStorage(DB::db(), 'game_env')->getValues([
'block_change_scout',
]);
}
if ($actionName === 'RaiseInvader') {
$response['createdEvents'] = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'id',
'targetCode' => 'target',
'priority' => 'priority',
'condition' => 'condition',
'action' => 'action',
],
['condition', 'action'],
),
iterator_to_array(
$db->query(
'SELECT id, target, priority, `condition`, action FROM event WHERE id > %i ORDER BY id',
$eventIdBeforeAction,
),
),
);
$response['createdDiplomacy'] = iterator_to_array(
$db->query(
'SELECT me AS fromNationId, you AS toNationId, state, term FROM diplomacy WHERE me > %i OR you > %i ORDER BY me, you',
$nationIdBeforeAction,
$nationIdBeforeAction,
),
);
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
$response['gameEnvironment'] = $gameStorage->getValues([
'isunited',
'turnterm',
'block_change_scout',
'refreshLimit',
]);
}
if (in_array($actionName, ['AutoDeleteInvader', 'InvaderEnding'], true)) {
$gameStorage = KVStorage::getStorage(DB::db(), 'game_env');
$response['gameEnvironment'] = $gameStorage->getValues([
'isunited',
'turnterm',
'block_change_scout',
'refreshLimit',
]);
}
if (($request['compactOutput'] ?? false) === true) {
$observedGeneralTurns = [];
foreach (comparisonIntegerList($request['observe']['generalIds'] ?? [], 'generalIds') as $generalId) {
$observedGeneralTurns[(string)$generalId] = iterator_to_array(
$db->query(
'SELECT turn_idx AS turnIndex, action, arg FROM general_turn WHERE general_id = %i ORDER BY turn_idx',
$generalId,
),
);
}
$response = [
'schemaVersion' => 1,
'engine' => 'ref',
'action' => $actionName,
'afterDetails' => $afterDetails,
'createdGeneralCount' => count($createdGenerals),
'firstCreatedGenerals' => array_slice($createdGenerals, 0, 3),
'createdNations' => $response['createdNations'] ?? [],
'createdEvents' => $response['createdEvents'] ?? [],
'createdDiplomacy' => $response['createdDiplomacy'] ?? [],
'gameEnvironment' => $response['gameEnvironment'] ?? [],
'logs' => $after['logs'],
'observedGeneralTurns' => $observedGeneralTurns,
'remainingEventIds' => array_map(
'intval',
$db->queryFirstColumn('SELECT id FROM event ORDER BY id'),
),
];
}
echo json_encode(
$response,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
exit(1);
}
}
comparisonMonthlyEventTraceMain();
+153
View File
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli' || getenv('REF_DETERMINISTIC_INSTALL_ENABLED') !== '1') {
http_response_code(404);
exit(1);
}
chdir(dirname(__DIR__));
require_once 'lib.php';
require_once 'func.php';
/** @return mixed */
function monthlySnapshotJson(mixed $value): mixed
{
if (!is_string($value) || $value === '') {
return $value;
}
try {
return json_decode($value, true, flags: JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return $value;
}
}
/** @return array<string, mixed> */
function monthlySnapshotPick(array $row, array $mapping, array $jsonColumns = []): array
{
$result = [];
foreach ($mapping as $target => $source) {
$value = $row[$source] ?? null;
$result[$target] = in_array($source, $jsonColumns, true)
? monthlySnapshotJson($value)
: $value;
}
return $result;
}
$db = DB::db();
$game = KVStorage::getStorage($db, 'game_env');
$game->resetCache();
$clock = GameClock::fromStorage($game);
$world = $game->getValues([
'scenario', 'year', 'month', 'turnterm', 'turntime', 'starttime',
'init_year', 'init_month', 'develcost', 'killturn', 'isunited',
]);
$world['turntime'] = $clock->formatTick(Util::toInt($world['turntime']), true);
$world['starttime'] = $clock->formatTick(Util::toInt($world['starttime']), true);
$generals = [];
foreach ($db->query('SELECT * FROM general ORDER BY no') as $row) {
$general = monthlySnapshotPick($row, [
'id' => 'no', 'name' => 'name', 'nationId' => 'nation', 'cityId' => 'city',
'troopId' => 'troop', 'leadership' => 'leadership', 'strength' => 'strength',
'intelligence' => 'intel', 'experience' => 'experience', 'experienceLevel' => 'explevel',
'dedication' => 'dedication', 'dedicationLevel' => 'dedlevel',
'leadershipExp' => 'leadership_exp', 'strengthExp' => 'strength_exp', 'intelligenceExp' => 'intel_exp',
'officerLevel' => 'officer_level', 'injury' => 'injury', 'gold' => 'gold',
'rice' => 'rice', 'crew' => 'crew', 'crewTypeId' => 'crewtype', 'train' => 'train',
'atmos' => 'atmos', 'age' => 'age', 'npcState' => 'npc', 'turnTime' => 'turntime',
'recentWarTime' => 'recent_war', 'lastTurn' => 'last_turn', 'meta' => 'aux',
'affinity' => 'affinity', 'birthYear' => 'bornyear', 'deathYear' => 'deadyear',
'personality' => 'personal', 'specialDomestic' => 'special', 'specialWar' => 'special2',
'horse' => 'horse', 'weapon' => 'weapon', 'book' => 'book', 'item' => 'item',
'specAge' => 'specage', 'specAge2' => 'specage2',
'dex1' => 'dex1', 'dex2' => 'dex2', 'dex3' => 'dex3', 'dex4' => 'dex4', 'dex5' => 'dex5',
'killTurn' => 'killturn',
], ['last_turn', 'aux']);
foreach (['id', 'nationId', 'cityId', 'troopId', 'leadership', 'strength', 'intelligence',
'experience', 'experienceLevel', 'dedication', 'dedicationLevel', 'leadershipExp',
'strengthExp', 'intelligenceExp', 'officerLevel', 'injury', 'gold', 'rice', 'crew',
'crewTypeId', 'train', 'atmos', 'age', 'npcState', 'dex1', 'dex2', 'dex3',
'dex4', 'dex5', 'killTurn', 'affinity', 'birthYear', 'deathYear', 'specAge', 'specAge2'] as $key) {
$general[$key] = (int)$general[$key];
}
$general['turnTick'] = Util::toInt($general['turnTime']);
$general['turnTime'] = $clock->formatTick($general['turnTick'], true);
$general['recentWarTick'] = $general['recentWarTime'] === null
? null
: Util::toInt($general['recentWarTime']);
$general['recentWarTime'] = $general['recentWarTime'] === null
? null
: $clock->formatTick(Util::toInt($general['recentWarTime']), true);
$generals[] = $general;
}
$cities = [];
foreach ($db->query('SELECT * FROM city ORDER BY city') as $row) {
$city = monthlySnapshotPick($row, [
'id' => 'city', 'name' => 'name', 'nationId' => 'nation', 'level' => 'level',
'population' => 'pop', 'populationMax' => 'pop_max', 'agriculture' => 'agri',
'agricultureMax' => 'agri_max', 'commerce' => 'comm', 'commerceMax' => 'comm_max',
'security' => 'secu', 'securityMax' => 'secu_max', 'supplyState' => 'supply',
'frontState' => 'front', 'defence' => 'def', 'defenceMax' => 'def_max',
'wall' => 'wall', 'wallMax' => 'wall_max', 'state' => 'state', 'term' => 'term',
'trust' => 'trust', 'trade' => 'trade', 'conflict' => 'conflict',
], ['conflict']);
foreach (array_keys($city) as $key) {
if ($key !== 'name' && $key !== 'conflict' && $key !== 'trust' && $key !== 'trade') {
$city[$key] = (int)$city[$key];
}
}
$city['trade'] = $city['trade'] === null ? null : (int)$city['trade'];
$cities[] = $city;
}
$nations = [];
$lastAttackableByNation = KVStorage::getValuesFromInterNamespace($db, 'nation_env', 'last_attackable');
$lastCapitalMoveTrialByNation = KVStorage::getValuesFromInterNamespace($db, 'nation_env', 'last천도Trial');
foreach ($db->query('SELECT *, CAST(tech AS DOUBLE) AS tech_raw FROM nation ORDER BY nation') as $row) {
$nation = monthlySnapshotPick($row, [
'id' => 'nation', 'name' => 'name', 'color' => 'color', 'capitalCityId' => 'capital',
'gold' => 'gold', 'rice' => 'rice', 'tech' => 'tech', 'level' => 'level',
'typeCode' => 'type', 'generalCount' => 'gennum', 'power' => 'power',
'war' => 'war', 'diplomacyLimit' => 'surlimit', 'capitalRevision' => 'capset',
'strategicCommandLimit' => 'strategic_cmd_limit', 'rate' => 'rate',
'rateTmp' => 'rate_tmp', 'bill' => 'bill', 'meta' => 'aux',
], ['aux']);
foreach (['id', 'capitalCityId', 'gold', 'rice', 'level', 'generalCount',
'power', 'war', 'diplomacyLimit', 'capitalRevision', 'strategicCommandLimit',
'rate', 'rateTmp', 'bill'] as $key) {
$nation[$key] = (int)$nation[$key];
}
$nation['tech'] = (float)$nation['tech'];
$nation['techRaw'] = (float)$row['tech_raw'];
$nation['lastAttackable'] = (int)($lastAttackableByNation[$nation['id']] ?? 0);
$lastCapitalMoveTrial = $lastCapitalMoveTrialByNation[$nation['id']] ?? null;
$nation['lastCapitalMoveTrial'] = is_array($lastCapitalMoveTrial)
? [(int)($lastCapitalMoveTrial[0] ?? 0), (int)($lastCapitalMoveTrial[1] ?? 0)]
: null;
$nations[] = $nation;
}
$diplomacy = [];
foreach ($db->query('SELECT me, you, state, term, dead FROM diplomacy ORDER BY me, you') as $row) {
$diplomacy[] = [
'fromNationId' => (int)$row['me'], 'toNationId' => (int)$row['you'],
'state' => (int)$row['state'], 'term' => (int)$row['term'], 'dead' => (int)$row['dead'],
];
}
echo json_encode([
'schemaVersion' => 1,
'engine' => 'ref',
'world' => $world,
'generals' => $generals,
'cities' => $cities,
'nations' => $nations,
'diplomacy' => $diplomacy,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION), PHP_EOL;
@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli' || getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
http_response_code(404);
fwrite(STDERR, "NPC possession comparison is disabled.\n");
exit(64);
}
chdir(dirname(__DIR__));
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
error_reporting(E_ALL & ~E_DEPRECATED);
require_once 'lib.php';
require_once 'func.php';
final class ComparisonNpcTracingRandUtil extends RandUtil
{
/** @var list<float> */
public array $floatDraws = [];
public function nextFloat1(): float
{
$value = parent::nextFloat1();
$this->floatDraws[] = $value;
return $value;
}
}
/** @return array<string, mixed> */
function comparisonNpcRequest(): array
{
$request = json_decode(stream_get_contents(STDIN), true, flags: JSON_THROW_ON_ERROR);
if (!is_array($request)) {
throw new \InvalidArgumentException('fixture must be a JSON object');
}
return $request;
}
/** @return list<int> */
function comparisonNpcIntegerList(mixed $value, string $name): array
{
if (!is_array($value)) {
throw new \InvalidArgumentException("{$name} must be an array");
}
$result = [];
foreach ($value as $item) {
if (!is_int($item) || $item <= 0) {
throw new \InvalidArgumentException("{$name} must contain positive integers");
}
$result[] = $item;
}
return $result;
}
/** @return array<int, int> */
function comparisonNpcPreviousPick(mixed $value): array
{
if ($value === null) {
return [];
}
if (!is_array($value)) {
throw new \InvalidArgumentException('previousPick must be an array');
}
$result = [];
foreach ($value as $item) {
if (
!is_array($item)
|| !isset($item['id'], $item['keepCount'])
|| !is_int($item['id'])
|| $item['id'] <= 0
|| !is_int($item['keepCount'])
|| $item['keepCount'] < 0
) {
throw new \InvalidArgumentException('previousPick entries require positive id and non-negative keepCount');
}
$result[$item['id']] = $item['keepCount'];
}
return $result;
}
/** @return list<array{id: int, leadership: int, strength: int, intel: int}> */
function comparisonNpcCandidateRows(): array
{
return array_map(
static fn(array $row): array => [
'id' => (int)$row['no'],
'leadership' => (int)$row['leadership'],
'strength' => (int)$row['strength'],
'intel' => (int)$row['intel'],
],
DB::db()->query('SELECT `no`, leadership, strength, intel FROM general WHERE npc=2'),
);
}
/** @return array<string, mixed> */
function comparisonNpcState(): array
{
return [
'candidates' => comparisonNpcCandidateRows(),
'tokens' => DB::db()->query(
'SELECT owner, valid_until, pick_more_from, pick_result, nonce FROM select_npc_token ORDER BY id',
),
];
}
/**
* @param array<string, mixed> $case
* @return array<string, mixed>
*/
function comparisonNpcRunCase(array $case, string|int $hiddenSeed, int $owner, int $now, GameClock $clock): array
{
$db = DB::db();
$db->delete('select_npc_token', '1=1');
$insertReserved = static function (int $reservedOwner, array $reservedIds, int $validUntil) use ($db, $now): void {
if ($reservedIds === []) {
return;
}
$reservedPick = [];
foreach ($reservedIds as $id) {
$reservedPick[$id] = ['keepCnt' => 3];
}
$db->insert('select_npc_token', [
'owner' => $reservedOwner,
'valid_until' => $validUntil,
'pick_more_from' => $now,
'pick_result' => json_encode((object)$reservedPick, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'nonce' => 1,
]);
};
$reservedIds = comparisonNpcIntegerList($case['reservedIds'] ?? [], 'reservedIds');
$boundaryReservedIds = comparisonNpcIntegerList(
$case['boundaryReservedIds'] ?? [],
'boundaryReservedIds',
);
$expiredReservedIds = comparisonNpcIntegerList(
$case['expiredReservedIds'] ?? [],
'expiredReservedIds',
);
$expiredAt = $now - $clock->ticksFromSeconds(1);
$insertReserved($owner + 1, $reservedIds, GameClock::MAX_SAFE_TICK);
$insertReserved($owner + 2, $boundaryReservedIds, $now);
$insertReserved($owner + 3, $expiredReservedIds, $expiredAt);
$candidates = [];
$weights = [];
foreach (comparisonNpcCandidateRows() as $row) {
$id = $row['id'];
$candidates[$id] = $row + ['keepCnt' => 3];
$weights[$id] = NpcPossessionSelector::weight($row);
}
NpcPossessionSelector::removeReserved(
$candidates,
$weights,
$db->queryFirstColumn(
'SELECT pick_result FROM select_npc_token WHERE `owner`!=%i AND valid_until >=%i',
$owner,
$now,
),
);
$previousPick = comparisonNpcPreviousPick($case['previousPick'] ?? null);
$keepIds = comparisonNpcIntegerList($case['keepIds'] ?? [], 'keepIds');
$oldPick = [];
foreach ($previousPick as $id => $keepCount) {
if (!isset($candidates[$id])) {
throw new \InvalidArgumentException("previous candidate {$id} is not selectable");
}
$oldPick[$id] = $candidates[$id];
$oldPick[$id]['keepCnt'] = $keepCount;
}
$hasPreviousToken = ($case['hasPreviousToken'] ?? false) === true || $oldPick !== [];
if ($hasPreviousToken) {
$db->insert('select_npc_token', [
'owner' => $owner,
'valid_until' => GameClock::MAX_SAFE_TICK,
'pick_more_from' => $now,
'pick_result' => json_encode((object)$oldPick, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'nonce' => 2,
]);
}
$before = comparisonNpcState();
$keepSelection = NpcPossessionSelector::applyKeep($oldPick, $keepIds);
$picked = $keepSelection['picked'];
if ($hasPreviousToken && $keepSelection['cancelled']) {
return [
'name' => (string)($case['name'] ?? ''),
'cancelled' => true,
'seed' => null,
'candidateOrder' => array_keys($candidates),
'randomDraws' => [],
'draws' => [],
'pick' => array_map(
static fn(int $id, array $candidate): array => [
'id' => $id,
'keepCount' => (int)$candidate['keepCnt'],
],
array_keys($oldPick),
array_values($oldPick),
),
'selectionStateUnchanged' => comparisonNpcState() === $before,
];
}
$seed = NpcPossessionSelector::buildSeed($hiddenSeed, $owner, $now);
$rng = new ComparisonNpcTracingRandUtil(new LiteHashDRBG($seed));
$draws = [];
$picked = NpcPossessionSelector::select(
$candidates,
$weights,
$picked,
$rng,
static function (int|string $id) use (&$draws): void {
$draws[] = (int)$id;
},
);
return [
'name' => (string)($case['name'] ?? ''),
'cancelled' => false,
'seed' => $seed,
'candidateOrder' => array_keys($candidates),
'randomDraws' => $rng->floatDraws,
'draws' => $draws,
'pick' => array_map(
static fn(int $id, array $candidate): array => [
'id' => $id,
'keepCount' => (int)$candidate['keepCnt'],
],
array_keys($picked),
array_values($picked),
),
'selectionStateUnchanged' => comparisonNpcState() === $before,
];
}
$request = comparisonNpcRequest();
$hiddenSeed = $request['hiddenSeed'] ?? null;
$owner = $request['owner'] ?? null;
$now = $request['now'] ?? null;
$candidateFixtures = $request['candidates'] ?? null;
$cases = $request['cases'] ?? null;
if (
(!is_string($hiddenSeed) && !is_int($hiddenSeed))
|| !is_int($owner)
|| $owner <= 0
|| !is_string($now)
|| !is_array($candidateFixtures)
|| !is_array($cases)
) {
throw new \InvalidArgumentException('fixture requires hiddenSeed, positive owner, now, candidates and cases');
}
$date = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $now);
if ($date === false || $date->format('Y-m-d H:i:s') !== $now) {
throw new \InvalidArgumentException('now must use Y-m-d H:i:s');
}
$db = DB::db();
$gameStorage = KVStorage::getStorage($db, 'game_env');
$clock = GameClock::fromStorage($gameStorage);
$nowTick = $clock->dateTimeToTick($date);
$db->update('general', ['npc' => 1], '1=1');
$fixtureIds = [];
foreach ($candidateFixtures as $candidate) {
if (
!is_array($candidate)
|| !isset($candidate['id'], $candidate['leadership'], $candidate['strength'], $candidate['intel'])
|| !is_int($candidate['id'])
|| $candidate['id'] <= 0
|| !is_int($candidate['leadership'])
|| !is_int($candidate['strength'])
|| !is_int($candidate['intel'])
) {
throw new \InvalidArgumentException('candidate entries require integer id and stats');
}
if ($db->queryFirstField('SELECT `no` FROM general WHERE `no`=%i', $candidate['id']) === null) {
throw new \RuntimeException("fixture general {$candidate['id']} does not exist");
}
$db->update(
'general',
[
'npc' => 2,
'leadership' => $candidate['leadership'],
'strength' => $candidate['strength'],
'intel' => $candidate['intel'],
],
'`no`=%i',
$candidate['id'],
);
$fixtureIds[] = $candidate['id'];
}
$results = [];
foreach ($cases as $case) {
if (!is_array($case)) {
throw new \InvalidArgumentException('case must be an object');
}
$results[] = comparisonNpcRunCase($case, $hiddenSeed, $owner, $nowTick, $clock);
}
echo json_encode(
[
'fixtureGeneralIds' => $fixtureIds,
'observedSqlOrder' => array_column(comparisonNpcCandidateRows(), 'id'),
'cases' => $results,
],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT,
);
echo PHP_EOL;
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use sammo\DB;
use sammo\Event\Action\OpenNationBetting;
use sammo\GameClock;
use sammo\Json;
use sammo\KVStorage;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
chdir(dirname(__DIR__));
require 'lib.php';
require 'func.php';
$db = DB::db();
$game = KVStorage::getStorage($db, 'game_env')->getAll();
$contract = [
'scenario' => (int)($game['scenario'] ?? 0),
'year' => (int)($game['year'] ?? 0),
'month' => (int)($game['month'] ?? 0),
'turnterm' => (int)($game['turnterm'] ?? 0),
'npcmode' => (int)($game['npcmode'] ?? 0),
];
if ($contract !== ['scenario' => 2601, 'year' => 186, 'month' => 1, 'turnterm' => 10, 'npcmode' => 2]) {
throw new RuntimeException('Ref GUI parity conditional fixture requires scenario2601 at 186-01.');
}
$nationBettingCount = (int)$db->queryFirstField(
"SELECT COUNT(*) FROM storage
WHERE namespace = 'betting'
AND JSON_UNQUOTE(JSON_EXTRACT(value, '$.type')) = 'bettingNation'"
);
if ($nationBettingCount === 0) {
(new OpenNationBetting(4, 5000))->run($game);
}
$uniqueCount = (int)$db->queryFirstField(
"SELECT COUNT(*) FROM ng_auction WHERE type = 'uniqueItem' AND finished = 0"
);
if ($uniqueCount === 0) {
$generalID = (int)$db->queryFirstField('SELECT no FROM general WHERE npc <= 2 ORDER BY no LIMIT 1');
if ($generalID <= 0) {
throw new RuntimeException('Ref GUI parity fixture requires at least one general.');
}
$gameStorage = KVStorage::getStorage($db, 'game_env');
$clock = GameClock::fromStorage($gameStorage);
$openTick = $clock->nowTick();
$closeTick = GameClock::addTicks($openTick, $clock->ticksFromMinutes(24 * 60));
$latestBidCloseTick = GameClock::addTicks($openTick, $clock->ticksFromMinutes(2 * 24 * 60));
$openDate = $clock->tickToDateTime($openTick);
$detail = [
'title' => '손자병법 경매',
'hostName' => '청룡',
'amount' => 1,
'isReverse' => false,
'startBidAmount' => 1000,
'finishBidAmount' => null,
'remainCloseDateExtensionCnt' => 1,
'availableLatestBidCloseTick' => $latestBidCloseTick,
];
$db->insert('ng_auction', [
'type' => 'uniqueItem',
'finished' => 0,
'target' => 'che_서적_15_손자병법',
'host_general_id' => $generalID,
'req_resource' => 'inheritPoint',
'open_tick' => $openTick,
'close_tick' => $closeTick,
'detail' => Json::encode($detail),
]);
$auctionID = (int)$db->insertId();
$db->insert('ng_auction_bid', [
'auction_id' => $auctionID,
'owner' => null,
'general_id' => $generalID,
'amount' => 1000,
'date' => $openDate->format('Y-m-d H:i:s'),
'aux' => Json::encode(['generalName' => '청룡']),
]);
}
printf(
"Ref conditional capture fixtures: nation_betting=%d unique_auction=%d\n",
(int)$db->queryFirstField(
"SELECT COUNT(*) FROM storage
WHERE namespace = 'betting'
AND JSON_UNQUOTE(JSON_EXTRACT(value, '$.type')) = 'bettingNation'"
),
(int)$db->queryFirstField("SELECT COUNT(*) FROM ng_auction WHERE type = 'uniqueItem'")
);
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
require_once 'lib.php';
require_once 'func.php';
$groups = [
'nation' => ['ActionNationType', 'ActionNationType'],
'domestic' => ['ActionSpecialDomestic', 'ActionSpecialDomestic'],
'war' => ['ActionSpecialWar', 'ActionSpecialWar'],
'personality' => ['ActionPersonality', 'ActionPersonality'],
];
$result = [];
foreach ($groups as $kind => [$directory, $namespace]) {
foreach (glob(__DIR__ . "/../sammo/{$directory}/*.php") ?: [] as $path) {
$key = basename($path, '.php');
if ($key === 'None' || str_starts_with($key, 'che_event_') || $key === 'che_거상') {
continue;
}
$className = "\\sammo\\{$namespace}\\{$key}";
$trait = new $className();
$result[$kind][$key] = [
'name' => $trait->getName(),
'info' => $trait->getInfo(),
];
}
ksort($result[$kind]);
}
echo Json::encode($result);
+950
View File
@@ -0,0 +1,950 @@
<?php
declare(strict_types=1);
namespace sammo;
use sammo\Enums\RankColumn;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
error_reporting(E_ALL & ~E_DEPRECATED);
require_once 'lib.php';
require_once 'func.php';
require_once __DIR__ . '/turn_state_snapshot.php';
final class TurnComparisonTracingRNG implements RNG
{
private int $sequence = 0;
/** @var list<array<string, mixed>> */
public array $calls = [];
public function __construct(private readonly RNG $inner)
{
}
public static function getMaxInt(): int
{
return LiteHashDRBG::getMaxInt();
}
public function nextBytes(int $bytes): string
{
$value = $this->inner->nextBytes($bytes);
$this->record('nextBytes', ['bytes' => $bytes], bin2hex($value));
return $value;
}
public function nextBits(int $bits): string
{
$value = $this->inner->nextBits($bits);
$this->record('nextBits', ['bits' => $bits], bin2hex($value));
return $value;
}
public function nextInt(?int $max = null): int
{
$value = $this->inner->nextInt($max);
$this->record('nextInt', ['maxInclusive' => $max], $value);
return $value;
}
public function nextFloat1(): float
{
$value = $this->inner->nextFloat1();
$this->record('nextFloat1', [], $value);
return $value;
}
private function record(string $operation, array $arguments, mixed $result): void
{
$this->calls[] = [
'seq' => $this->sequence++,
'operation' => $operation,
'arguments' => $arguments === [] ? (object)[] : $arguments,
'result' => $result,
];
}
}
/** @return array<string, mixed> */
function comparisonMappedPatch(array $row, array $mapping): array
{
$patch = [];
foreach ($mapping as $canonical => $legacy) {
if (array_key_exists($canonical, $row)) {
$patch[$legacy] = $row[$canonical];
}
}
return $patch;
}
function comparisonApplyTurnFixtureSetup(mixed $setup): void
{
if ($setup === null) {
return;
}
if (!is_array($setup)) {
throw new \InvalidArgumentException('setup must be an object');
}
$db = DB::db();
$world = $setup['world'] ?? null;
if ($world !== null) {
if (!is_array($world)) {
throw new \InvalidArgumentException('setup.world must be an object');
}
$freezeClock = $world['freezeClock'] ?? false;
if (!is_bool($freezeClock)) {
throw new \InvalidArgumentException('setup.world.freezeClock must be a boolean');
}
$game = KVStorage::getStorage($db, 'game_env');
foreach (['year', 'month', 'startyear', 'init_year', 'init_month'] as $key) {
$fixtureKey = match ($key) {
'startyear' => 'startYear',
'init_year' => 'initYear',
'init_month' => 'initMonth',
default => $key,
};
if (!array_key_exists($fixtureKey, $world)) {
continue;
}
$value = $world[$fixtureKey];
if (
!is_int($value)
|| $value < 1
|| (in_array($key, ['month', 'init_month'], true) && $value > 12)
) {
throw new \InvalidArgumentException("setup.world.{$fixtureKey} is invalid");
}
$game->setValue($key, $value);
}
$game->resetCache();
if (array_key_exists('hiddenSeed', $world)) {
$hiddenSeed = $world['hiddenSeed'];
if (!is_string($hiddenSeed) || $hiddenSeed === '' || strlen($hiddenSeed) > 256) {
throw new \InvalidArgumentException('setup.world.hiddenSeed is invalid');
}
UniqueConst::$hiddenSeed = $hiddenSeed;
}
// 비교 fixture는 전역 상태에 의존하지 않도록 effect 기본값을 null로 고정하고,
// 명시된 경우에만 해당 effect를 활성화합니다.
GameConst::$scenarioEffect = null;
if (array_key_exists('scenarioEffect', $world)) {
$scenarioEffect = $world['scenarioEffect'];
if ($scenarioEffect === '' || $scenarioEffect === 'None') {
$scenarioEffect = null;
}
if ($scenarioEffect !== null) {
if (!is_string($scenarioEffect) || getScenarioEffectClass($scenarioEffect) === null) {
throw new \InvalidArgumentException('setup.world.scenarioEffect is invalid');
}
}
GameConst::$scenarioEffect = $scenarioEffect;
}
if (array_key_exists('staticEventHandlers', $world)) {
$handlersByEvent = $world['staticEventHandlers'];
if (!is_array($handlersByEvent)) {
throw new \InvalidArgumentException('setup.world.staticEventHandlers must be an object');
}
foreach ($handlersByEvent as $eventType => $handlers) {
if (!is_string($eventType) || $eventType === '' || !is_array($handlers)) {
throw new \InvalidArgumentException('setup.world.staticEventHandlers is invalid');
}
foreach ($handlers as $handler) {
if (!is_string($handler) || $handler === '') {
throw new \InvalidArgumentException('setup.world.staticEventHandlers is invalid');
}
}
}
GameConst::$staticEventHandlers = $handlersByEvent;
}
if ($freezeClock) {
$clock = GameClock::fromStorage($game);
$frozenTick = $clock->nowTick();
$clock->persistTick($game, $frozenTick, GameClock::MODE_MANUAL);
$game->resetCache();
}
}
foreach (['nations', 'cities', 'generals', 'troops', 'diplomacy', 'generalTurns', 'nationTurns'] as $collection) {
if (isset($setup[$collection]) && !is_array($setup[$collection])) {
throw new \InvalidArgumentException("setup.{$collection} must be an array");
}
}
$generalCooldowns = comparisonGeneralCooldownSelectors(
$setup['generalCooldowns'] ?? [],
'setup.generalCooldowns',
);
if ($generalCooldowns !== []) {
$nextExecuteStorage = KVStorage::getStorage($db, 'next_execute');
foreach ($setup['generalCooldowns'] as $entry) {
$nextAvailableTurn = $entry['nextAvailableTurn'] ?? null;
if (!is_int($nextAvailableTurn) || $nextAvailableTurn < 0) {
throw new \InvalidArgumentException(
'setup.generalCooldowns entries require a non-negative nextAvailableTurn',
);
}
$key = "next_execute_{$entry['generalId']}_{$entry['actionName']}";
$nextExecuteStorage->setValue($key, $nextAvailableTurn);
}
$nextExecuteStorage->resetCache();
}
if (isset($setup['randomFoundingCandidateCityIds'])) {
$candidateCityIds = comparisonIntegerList(
$setup['randomFoundingCandidateCityIds'],
'randomFoundingCandidateCityIds',
);
$db->update('city', ['level' => 4], '1 = 1');
if ($candidateCityIds !== []) {
$db->update('city', ['level' => 5], 'city IN %li', $candidateCityIds);
}
}
if (($setup['isolateWorld'] ?? false) === true) {
$generalIds = array_values(array_map(
static fn(array $row): int => (int)$row['id'],
$setup['generals'] ?? [],
));
$nationIds = array_values(array_map(
static fn(array $row): int => (int)$row['id'],
$setup['nations'] ?? [],
));
if ($generalIds === [] || $nationIds === []) {
throw new \InvalidArgumentException('isolateWorld requires generals and nations');
}
$generalTemplate = $db->queryFirstRow(
'SELECT * FROM general WHERE no IN %li ORDER BY no LIMIT 1',
$generalIds,
);
if ($generalTemplate === null) {
throw new \InvalidArgumentException('isolateWorld requires at least one existing fixture general');
}
$generalTemplateId = (int)$generalTemplate['no'];
$rankDataTemplate = $db->query(
'SELECT `type`, `value` FROM rank_data WHERE general_id = %i',
$generalTemplateId,
);
$db->delete('general_turn', 'general_id NOT IN %li', $generalIds);
$db->delete('rank_data', 'general_id NOT IN %li', $generalIds);
$db->delete('nation_turn', 'nation_id NOT IN %li', $nationIds);
$db->delete('troop', '1 = 1');
$db->delete('diplomacy', '1 = 1');
$db->delete('general', 'no NOT IN %li', $generalIds);
foreach ($generalIds as $generalId) {
if ($db->queryFirstField('SELECT no FROM general WHERE no = %i', $generalId) !== null) {
continue;
}
$newGeneral = $generalTemplate;
$newGeneral['no'] = $generalId;
$newGeneral['owner'] = 0;
$newGeneral['name'] = "fixture-general-{$generalId}";
$db->insert('general', $newGeneral);
}
foreach ($generalIds as $generalId) {
foreach ($rankDataTemplate as $rankRow) {
$rankData = [
'general_id' => $generalId,
'type' => $rankRow['type'],
'value' => $rankRow['value'],
];
$db->insertUpdate('rank_data', $rankData, $rankData);
}
}
$db->delete('nation', 'nation NOT IN %li', $nationIds);
$db->update('city', [
'nation' => 0,
'supply' => 0,
'front' => 0,
'state' => 0,
'term' => 0,
'conflict' => '{}',
'officer_set' => 0,
], '1 = 1');
}
foreach ($setup['nations'] ?? [] as $row) {
if (!is_array($row) || !is_int($row['id'] ?? null) || $row['id'] < 1) {
throw new \InvalidArgumentException('setup.nations requires positive integer ids');
}
$data = comparisonMappedPatch($row, [
'id' => 'nation',
'name' => 'name',
'color' => 'color',
'capitalCityId' => 'capital',
'gold' => 'gold',
'rice' => 'rice',
'tech' => 'tech',
'level' => 'level',
'typeCode' => 'type',
'war' => 'war',
'diplomacyLimit' => 'surlimit',
'generalCount' => 'gennum',
'power' => 'power',
'capitalRevision' => 'capset',
'strategicCommandLimit' => 'strategic_cmd_limit',
]);
if (isset($row['meta'])) {
$data['aux'] = Json::encode($row['meta']);
}
$data += [
'name' => "fixture-nation-{$row['id']}",
'color' => '#777777',
'capital' => 0,
'gold' => 0,
'rice' => 0,
'level' => 1,
'type' => 'che_중립',
'aux' => '{}',
];
$db->insertUpdate('nation', $data, $data);
$nationStor = KVStorage::getStorage($db, $row['id'], 'nation_env');
if (($setup['isolateWorld'] ?? false) === true) {
$nationStor->deleteValue('recv_assist');
$nationStor->deleteValue('resp_assist');
}
if (array_key_exists('nationEnv', $row)) {
if (!is_array($row['nationEnv'])) {
throw new \InvalidArgumentException('setup.nations.nationEnv must be an object');
}
foreach ($row['nationEnv'] as $key => $value) {
if (!is_string($key) || $key === '') {
throw new \InvalidArgumentException('setup.nations.nationEnv keys must be non-empty strings');
}
$nationStor->setValue($key, $value);
}
$nationStor->resetCache();
}
if (array_key_exists('turnLastByOfficerLevel', $row)) {
$turnLastByOfficerLevel = $row['turnLastByOfficerLevel'];
if (!is_array($turnLastByOfficerLevel)) {
throw new \InvalidArgumentException('setup.nations.turnLastByOfficerLevel must be an object');
}
foreach ($turnLastByOfficerLevel as $officerLevel => $lastTurn) {
$officerLevel = filter_var($officerLevel, FILTER_VALIDATE_INT);
if (
$officerLevel === false
|| $officerLevel < 5
|| $officerLevel > 12
|| !is_array($lastTurn)
) {
throw new \InvalidArgumentException(
'setup.nations.turnLastByOfficerLevel requires officer levels 5 through 12'
);
}
$nationStor->setValue("turn_last_{$officerLevel}", $lastTurn);
}
$nationStor->resetCache();
}
}
if (($setup['isolateWorld'] ?? false) === true && ($setup['nations'] ?? []) !== []) {
$maxNationId = max(array_map(
static fn(array $row): int => (int)$row['id'],
$setup['nations'],
));
$db->query('ALTER TABLE nation AUTO_INCREMENT = %i', $maxNationId + 1);
}
foreach ($setup['cities'] ?? [] as $row) {
if (!is_array($row) || !is_int($row['id'] ?? null) || $row['id'] < 1) {
throw new \InvalidArgumentException('setup.cities requires positive integer ids');
}
$patch = comparisonMappedPatch($row, [
'nationId' => 'nation',
'level' => 'level',
'population' => 'pop',
'populationMax' => 'pop_max',
'agriculture' => 'agri',
'agricultureMax' => 'agri_max',
'commerce' => 'comm',
'commerceMax' => 'comm_max',
'security' => 'secu',
'securityMax' => 'secu_max',
'supplyState' => 'supply',
'frontState' => 'front',
'defence' => 'def',
'defenceMax' => 'def_max',
'wall' => 'wall',
'wallMax' => 'wall_max',
'state' => 'state',
'term' => 'term',
'trust' => 'trust',
'trade' => 'trade',
'officerSet' => 'officer_set',
]);
if (array_key_exists('conflictEntries', $row)) {
if (!is_array($row['conflictEntries'])) {
throw new \InvalidArgumentException('setup.cities.conflictEntries must be an array');
}
$orderedConflict = [];
foreach ($row['conflictEntries'] as $entry) {
if (
!is_array($entry)
|| count($entry) !== 2
|| !is_int($entry[0] ?? null)
|| $entry[0] < 1
|| !is_numeric($entry[1] ?? null)
) {
throw new \InvalidArgumentException(
'setup.cities.conflictEntries requires [positive nation id, numeric value] pairs'
);
}
$orderedConflict[$entry[0]] = $entry[1];
}
$patch['conflict'] = Json::encode($orderedConflict);
} elseif (array_key_exists('conflict', $row)) {
if (!is_array($row['conflict'])) {
throw new \InvalidArgumentException('setup.cities.conflict must be an object');
}
$patch['conflict'] = Json::encode($row['conflict']);
}
if ($patch !== []) {
$db->update('city', $patch, 'city = %i', $row['id']);
}
}
foreach ($setup['generals'] ?? [] as $row) {
if (!is_array($row) || !is_int($row['id'] ?? null) || $row['id'] < 1) {
throw new \InvalidArgumentException('setup.generals requires positive integer ids');
}
$patch = comparisonMappedPatch($row, [
'name' => 'name',
'nationId' => 'nation',
'cityId' => 'city',
'troopId' => 'troop',
'leadership' => 'leadership',
'strength' => 'strength',
'intelligence' => 'intel',
'leadershipExp' => 'leadership_exp',
'strengthExp' => 'strength_exp',
'intelExp' => 'intel_exp',
'experience' => 'experience',
'dedication' => 'dedication',
'expLevel' => 'explevel',
'officerLevel' => 'officer_level',
'officerCityId' => 'officer_city',
'belong' => 'belong',
'permission' => 'permission',
'betray' => 'betray',
'makeLimit' => 'makelimit',
'injury' => 'injury',
'age' => 'age',
'gold' => 'gold',
'rice' => 'rice',
'crew' => 'crew',
'crewTypeId' => 'crewtype',
'train' => 'train',
'atmos' => 'atmos',
'dex1' => 'dex1',
'dex2' => 'dex2',
'dex3' => 'dex3',
'dex4' => 'dex4',
'dex5' => 'dex5',
'specAge' => 'specage',
'specAge2' => 'specage2',
'killTurn' => 'killturn',
'npcState' => 'npc',
'blockState' => 'block',
'picture' => 'picture',
'imageServer' => 'imgsvr',
'specialDomestic' => 'special',
'specialWar' => 'special2',
'personality' => 'personal',
'itemHorse' => 'horse',
'itemWeapon' => 'weapon',
'itemBook' => 'book',
'itemExtra' => 'item',
]);
if (isset($row['meta'])) {
$patch['aux'] = Json::encode($row['meta']);
}
if (array_key_exists('penalty', $row)) {
if (!is_array($row['penalty'])) {
throw new \InvalidArgumentException('setup.generals.penalty must be an object');
}
$patch['penalty'] = Json::encode($row['penalty']);
}
if (isset($row['lastTurn']) && is_array($row['lastTurn'])) {
$patch['last_turn'] = Json::encode($row['lastTurn']);
}
if ($patch !== []) {
$db->update('general', $patch, 'no = %i', $row['id']);
}
if (array_key_exists('nationId', $row)) {
$db->update('rank_data', [
'nation_id' => $row['nationId'],
], 'general_id = %i', $row['id']);
}
}
foreach ($setup['rankData'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['generalId'] ?? null)
|| !is_string($row['type'] ?? null)
|| RankColumn::tryFrom($row['type']) === null
|| !is_int($row['value'] ?? null)
) {
throw new \InvalidArgumentException('invalid setup.rankData row');
}
$nationId = (int)($db->queryFirstField(
'SELECT nation FROM general WHERE no = %i',
$row['generalId'],
) ?? 0);
$db->insertUpdate('rank_data', [
'general_id' => $row['generalId'],
'nation_id' => $nationId,
'type' => $row['type'],
'value' => $row['value'],
], [
'nation_id' => $nationId,
'value' => $row['value'],
]);
}
if (($setup['isolateWorld'] ?? false) === true && ($setup['generals'] ?? []) !== []) {
$maxGeneralId = max(array_map(
static fn(array $row): int => (int)$row['id'],
$setup['generals'],
));
$db->query('ALTER TABLE general AUTO_INCREMENT = %i', $maxGeneralId + 1);
}
foreach ($setup['troops'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['id'] ?? null)
|| !is_int($row['nationId'] ?? null)
|| !is_string($row['name'] ?? null)
|| $row['id'] < 1
|| $row['nationId'] < 1
|| $row['name'] === ''
) {
throw new \InvalidArgumentException('setup.troops requires positive id/nationId and non-empty name');
}
$data = [
'troop_leader' => $row['id'],
'nation' => $row['nationId'],
'name' => $row['name'],
];
$db->insertUpdate('troop', $data, $data);
}
foreach ($setup['diplomacy'] ?? [] as $row) {
if (
!is_array($row) ||
!is_int($row['fromNationId'] ?? null) ||
!is_int($row['toNationId'] ?? null) ||
$row['fromNationId'] < 1 ||
$row['toNationId'] < 1
) {
throw new \InvalidArgumentException('setup.diplomacy requires positive integer nation ids');
}
$data = comparisonMappedPatch($row, [
'fromNationId' => 'me',
'toNationId' => 'you',
'state' => 'state',
'term' => 'term',
'dead' => 'dead',
]);
$data += ['state' => 3, 'term' => 0, 'dead' => 0];
$db->insertUpdate('diplomacy', $data, $data);
}
foreach ($setup['generalTurns'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['generalId'] ?? null)
|| !is_int($row['turnIndex'] ?? null)
|| !is_string($row['action'] ?? null)
|| $row['generalId'] < 1
|| $row['turnIndex'] < 0
|| $row['turnIndex'] >= GameConst::$maxTurn
|| $row['action'] === ''
) {
throw new \InvalidArgumentException('setup.generalTurns entries are invalid');
}
$data = [
'general_id' => $row['generalId'],
'turn_idx' => $row['turnIndex'],
'action' => $row['action'],
'arg' => Json::encode($row['args'] ?? []),
'brief' => $row['action'],
];
$db->insertUpdate('general_turn', $data, $data);
}
foreach ($setup['nationTurns'] ?? [] as $row) {
if (
!is_array($row)
|| !is_int($row['nationId'] ?? null)
|| !is_int($row['officerLevel'] ?? null)
|| !is_int($row['turnIndex'] ?? null)
|| !is_string($row['action'] ?? null)
|| $row['nationId'] < 1
|| $row['officerLevel'] < 5
|| $row['turnIndex'] < 0
|| $row['turnIndex'] >= GameConst::$maxChiefTurn
|| $row['action'] === ''
) {
throw new \InvalidArgumentException('setup.nationTurns entries are invalid');
}
$data = [
'nation_id' => $row['nationId'],
'officer_level' => $row['officerLevel'],
'turn_idx' => $row['turnIndex'],
'action' => $row['action'],
'arg' => Json::encode($row['args'] ?? []),
'brief' => $row['action'],
];
$db->insertUpdate('nation_turn', $data, $data);
}
}
/** @return array{generalIds: list<int>, cityIds: list<int>, nationIds: list<int>, troopIds: list<int>} */
function comparisonTurnCommandEntityIds(): array
{
$db = DB::db();
return [
'generalIds' => array_map('intval', $db->queryFirstColumn('SELECT no FROM general ORDER BY no')),
'cityIds' => array_map('intval', $db->queryFirstColumn('SELECT city FROM city ORDER BY city')),
'nationIds' => array_map('intval', $db->queryFirstColumn('SELECT nation FROM nation ORDER BY nation')),
'troopIds' => array_map('intval', $db->queryFirstColumn('SELECT troop_leader FROM troop ORDER BY troop_leader')),
];
}
/**
* Extend an explicit selector with entities created by the command. Without
* this closure, a successful founding/recruitment can remain absent from both
* snapshots and therefore produce a false green differential.
*
* @param array{observe?: mixed} $snapshotRequest
* @param array{generalIds: list<int>, cityIds: list<int>, nationIds: list<int>, troopIds: list<int>} $before
* @return array{observe: array<string, mixed>}
*/
function comparisonCloseSnapshotRequestOverCreatedEntities(array $snapshotRequest, array $before): array
{
$observe = $snapshotRequest['observe'] ?? [];
if (!is_array($observe)) {
throw new \InvalidArgumentException('observe must be an object');
}
$after = comparisonTurnCommandEntityIds();
foreach (['generalIds', 'cityIds', 'nationIds', 'troopIds'] as $selector) {
$selected = comparisonIntegerList($observe[$selector] ?? [], $selector);
$created = array_values(array_diff($after[$selector], $before[$selector]));
$observe[$selector] = array_values(array_unique([...$selected, ...$created]));
sort($observe[$selector], SORT_NUMERIC);
}
return ['observe' => $observe];
}
function comparisonRunTurnCommand(array $request): array
{
if (getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
throw new \RuntimeException('TURN_DIFFERENTIAL_ENABLED=1 is required');
}
$kind = $request['kind'] ?? null;
if (!in_array($kind, ['general', 'nation', 'instantNation', 'troopJoinStaticEvent'], true)) {
throw new \InvalidArgumentException(
'kind must be general, nation, instantNation, or troopJoinStaticEvent',
);
}
$actorGeneralId = $request['actorGeneralId'] ?? null;
$action = $request['action'] ?? null;
$args = $request['args'] ?? null;
$includeLifecycle = $request['includeLifecycle'] ?? false;
if (!is_int($actorGeneralId) || $actorGeneralId < 1) {
throw new \InvalidArgumentException('actorGeneralId must be a positive integer');
}
if (!is_string($action) || $action === '') {
throw new \InvalidArgumentException('action must be a non-empty string');
}
if (!is_bool($includeLifecycle)) {
throw new \InvalidArgumentException('includeLifecycle must be a boolean');
}
if ($includeLifecycle && !in_array($kind, ['general', 'nation'], true)) {
throw new \InvalidArgumentException('includeLifecycle supports general and nation commands only');
}
comparisonApplyTurnFixtureSetup($request['setup'] ?? null);
if ($includeLifecycle) {
if ($kind === 'general') {
DB::db()->insertUpdate('general_turn', [
'general_id' => $actorGeneralId,
'turn_idx' => 0,
'action' => $action,
'arg' => Json::encode($args),
'brief' => $action,
], [
'action' => $action,
'arg' => Json::encode($args),
'brief' => $action,
]);
} else {
$actorOfficer = DB::db()->queryFirstRow(
'SELECT nation, officer_level FROM general WHERE no = %i',
$actorGeneralId,
);
if ($actorOfficer === null) {
throw new \RuntimeException('장수 정보를 불러올 수 없습니다.');
}
$actorNationId = (int)$actorOfficer['nation'];
$actorOfficerLevel = (int)$actorOfficer['officer_level'];
if ($actorNationId < 1 || $actorOfficerLevel < 5) {
throw new \InvalidArgumentException('nation lifecycle requires a nation officer');
}
DB::db()->insertUpdate('nation_turn', [
'nation_id' => $actorNationId,
'officer_level' => $actorOfficerLevel,
'turn_idx' => 0,
'action' => $action,
'arg' => Json::encode($args),
'brief' => $action,
], [
'action' => $action,
'arg' => Json::encode($args),
'brief' => $action,
]);
}
}
$snapshotRequest = ['observe' => $request['observe'] ?? []];
$beforeEntityIds = comparisonTurnCommandEntityIds();
$before = comparisonTurnStateSnapshot($snapshotRequest);
$db = DB::db();
$gameStorage = KVStorage::getStorage($db, 'game_env');
$gameStorage->resetCache();
$environment = $gameStorage->getAll();
if ($kind === 'troopJoinStaticEvent') {
if ($action !== \sammo\API\Troop\JoinTroop::class) {
throw new \InvalidArgumentException('troopJoinStaticEvent requires the JoinTroop API action');
}
$troopId = is_array($args) ? ($args['troopID'] ?? null) : null;
if (!is_int($troopId) || $troopId < 1) {
throw new \InvalidArgumentException('troopJoinStaticEvent requires a positive integer troopID');
}
$general = GeneralLite::createObjFromDB(
$actorGeneralId,
['troop'],
Enums\GeneralLiteQueryMode::Lite,
);
if ($general === null) {
throw new \RuntimeException('장수 정보를 불러올 수 없습니다.');
}
if ($general->getVar('troop') != 0) {
throw new \RuntimeException('이미 부대에 소속되어 있습니다.');
}
$nationId = $general->getNationID();
if ($nationId == 0) {
throw new \RuntimeException('국가에 소속되어 있지 않습니다.');
}
$troopExists = $db->queryFirstField(
'SELECT `troop_leader` FROM `troop` WHERE `troop_leader` = %i AND `nation` = %i',
$troopId,
$nationId,
);
if (!$troopExists) {
throw new \RuntimeException('부대가 올바르지 않습니다.');
}
GameConst::$staticEventHandlers = [
\sammo\API\Troop\JoinTroop::class => ['event_부대탑승즉시이동'],
];
$general->setVar('troop', $troopId);
StaticEventHandler::handleEvent($general, null, \sammo\API\Troop\JoinTroop::class, [], $args);
$general->applyDB($db);
$after = comparisonTurnStateSnapshot(
comparisonCloseSnapshotRequestOverCreatedEntities($snapshotRequest, $beforeEntityIds),
);
return [
'schemaVersion' => 1,
'engine' => 'ref',
'execution' => [
'kind' => $kind,
'actorGeneralId' => $actorGeneralId,
'action' => $action,
'args' => $args,
'seedDomain' => 'none',
'outcome' => ['completed' => true],
],
'before' => $before,
'after' => $after,
'rng' => [],
];
}
$general = General::createObjFromDB($actorGeneralId);
if ($kind === 'instantNation') {
$command = buildNationCommandClass($action, $general, $environment, new LastTurn(), $args);
if (!$command->hasFullConditionMet()) {
throw new \RuntimeException($command->getFailString());
}
$completed = $command->run(NoRNG::rngInstance());
$command->setNextAvailable();
$general->getLogger()->flush();
$after = comparisonTurnStateSnapshot(
comparisonCloseSnapshotRequestOverCreatedEntities($snapshotRequest, $beforeEntityIds),
);
return [
'schemaVersion' => 1,
'engine' => 'ref',
'execution' => [
'kind' => $kind,
'actorGeneralId' => $actorGeneralId,
'action' => $action,
'args' => $args,
'seedDomain' => 'none',
'outcome' => [
'lastTurn' => (object)[],
'commandName' => $command->getName(),
'completed' => $completed,
],
],
'before' => $before,
'after' => $after,
'rng' => [],
];
}
$turn = new TurnExecutionHelper($general);
$general->increaseInheritancePoint(Enums\InheritanceKey::lived_month, 1);
$preprocessRng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
UniqueConst::$hiddenSeed,
'preprocess',
(int)$environment['year'],
(int)$environment['month'],
$actorGeneralId,
)));
$turn->preprocessCommand($preprocessRng, $environment);
$seedDomain = $kind === 'general' ? 'generalCommand' : 'nationCommand';
$seed = Util::simpleSerialize(
UniqueConst::$hiddenSeed,
$seedDomain,
(int)$environment['year'],
(int)$environment['month'],
$actorGeneralId,
$action,
);
$tracingRng = new TurnComparisonTracingRNG(new LiteHashDRBG($seed));
$rng = new RandUtil($tracingRng);
if ($kind === 'general' && $action === 'che_견문') {
\sammo\TextDecoration\SightseeingMessage::setComparisonRng($rng);
}
if ($kind === 'general') {
$previousLastTurn = $general->getLastTurn();
$previousLastTurnRaw = $previousLastTurn->toRaw();
$command = buildGeneralCommandClass($action, $general, $environment, $args);
$commandFullConditionMet = $command->hasFullConditionMet();
$resultTurn = $turn->processCommand($rng, $command, false);
} else {
$nationStor = KVStorage::getStorage($db, $general->getNationID(), 'nation_env');
$lastNationTurnKey = "turn_last_{$general->getVar('officer_level')}";
$previousLastTurn = LastTurn::fromRaw($nationStor->getValue($lastNationTurnKey));
$previousLastTurnRaw = $previousLastTurn->toRaw();
$command = buildNationCommandClass($action, $general, $environment, $previousLastTurn, $args);
$commandFullConditionMet = $command->hasFullConditionMet();
$resultTurn = $turn->processNationCommand($rng, $command);
$nationStor->setValue($lastNationTurnKey, $resultTurn->toRaw());
}
if ($includeLifecycle) {
if ($kind === 'general') {
pullGeneralCommand($general->getID());
} else {
pullNationCommand(
$general->getNationID(),
(int)$general->getVar('officer_level'),
);
}
$general->increaseVarWithLimit(
'myset',
GameConst::$incDefSettingChange,
null,
GameConst::$maxDefSettingChange,
);
$turn->updateTurnTime();
}
$general->getLogger()->flush();
$turn->applyDB();
unset($turn);
$after = comparisonTurnStateSnapshot(
comparisonCloseSnapshotRequestOverCreatedEntities($snapshotRequest, $beforeEntityIds),
);
$resultTurnRaw = $resultTurn->toRaw();
$resultTerm = (int)($resultTurnRaw['term'] ?? 0);
$preReqTurn = $command->getPreReqTurn();
$acceptScoutCompleted = (
$action === 'che_등용수락'
&& is_array($args)
&& is_int($args['destNationID'] ?? null)
&& $general->getNationID() === $args['destNationID']
);
$foundNationCompleted = (
in_array($action, ['che_건국', 'cr_건국', 'che_무작위건국'], true)
&& is_array($args)
&& is_string($args['nationName'] ?? null)
&& DB::db()->queryFirstField(
'SELECT name FROM nation WHERE nation = %i',
$general->getNationID(),
) === $args['nationName']
);
$completedByState = (
$action === 'che_접경귀환'
&& $tracingRng->calls !== []
) || $acceptScoutCompleted || $foundNationCompleted || (($resultTurnRaw['command'] ?? null) === $command->getName()
&& (
($preReqTurn === 0 && $resultTerm === 0)
|| (
$preReqTurn > 0
&& ($previousLastTurnRaw['command'] ?? null) === $command->getName()
&& ($previousLastTurnRaw['arg'] ?? null) === $command->getArg()
&& (int)($previousLastTurnRaw['term'] ?? 0) === $preReqTurn
)
));
$completed = $commandFullConditionMet && $completedByState;
return [
'schemaVersion' => 1,
'engine' => 'ref',
'harness' => [
'messageSharedIconBaseUrl' => ServConfig::getSharedIconPath(),
],
'execution' => [
'kind' => $kind,
'actorGeneralId' => $actorGeneralId,
'action' => $action,
'args' => $args,
'seedDomain' => $seedDomain,
'outcome' => [
'lastTurn' => $resultTurnRaw,
'commandName' => $command->getName(),
'completed' => $completed,
],
],
'before' => $before,
'after' => $after,
'rng' => $tracingRng->calls,
];
}
if (!defined('SAMMO_TURN_COMPARISON_LIBRARY_ONLY')) {
try {
$input = stream_get_contents(STDIN);
$request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($request)) {
throw new \InvalidArgumentException('request must be an object');
}
echo json_encode(
comparisonRunTurnCommand($request),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
exit(1);
}
} elseif (getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
throw new \RuntimeException('TURN_DIFFERENTIAL_ENABLED=1 is required for comparison library mode');
}
+247
View File
@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace sammo;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
if (getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
fwrite(STDERR, "TURN_DIFFERENTIAL_ENABLED=1 is required\n");
exit(1);
}
define('SAMMO_TURN_COMPARISON_LIBRARY_ONLY', true);
require_once __DIR__ . '/turn_command_trace.php';
/**
* Comparison-only subclass around the real product lifecycle entry point.
*
* executeGeneralCommandUntil() constructs `new static(...)`, so invoking the
* inherited method through this class lets the harness observe phase order
* without copying or changing the legacy lifecycle implementation.
*/
final class TurnFullLifecycleComparisonHelper extends TurnExecutionHelper
{
/** @var list<array<string, mixed>> */
private static array $phases = [];
private bool $recordedPersistence = false;
/** @return list<array<string, mixed>> */
public static function phases(): array
{
return self::$phases;
}
private function record(string $phase, array $detail = []): void
{
self::$phases[] = [
'sequence' => count(self::$phases),
'phase' => $phase,
'generalId' => $this->getGeneral()->getID(),
...$detail,
];
}
public function preprocessCommand(RandUtil $rng, array $env)
{
$this->record('preprocess');
return parent::preprocessCommand($rng, $env);
}
public function processBlocked(): bool
{
$blocked = parent::processBlocked();
$this->record('block', ['blocked' => $blocked]);
return $blocked;
}
public function processNationCommand(RandUtil $rng, Command\NationCommand $commandObj): LastTurn
{
$this->record('nation_command', ['action' => $commandObj->getRawClassName()]);
$result = parent::processNationCommand($rng, $commandObj);
$this->record('nation_command_resolved', ['lastTurn' => $result->toRaw()]);
return $result;
}
public function processCommand(RandUtil $rng, Command\GeneralCommand $commandObj, bool $autorunMode)
{
$this->record('general_command', ['action' => $commandObj->getRawClassName()]);
$result = parent::processCommand($rng, $commandObj, $autorunMode);
$this->record('general_command_resolved', ['lastTurn' => $result->toRaw()]);
return $result;
}
public function updateTurnTime()
{
$db = DB::db();
$general = $this->getGeneral();
$nationId = $general->getNationID();
$officerLevel = (int)$general->getVar('officer_level');
$this->record('queues_shifted', [
'generalAction' => $db->queryFirstField(
'SELECT action FROM general_turn WHERE general_id = %i AND turn_idx = 0',
$general->getID(),
),
'nationAction' => $db->queryFirstField(
'SELECT action FROM nation_turn WHERE nation_id = %i AND officer_level = %i AND turn_idx = 0',
$nationId,
$officerLevel,
),
]);
parent::updateTurnTime();
$this->record('turn_state_advanced', [
'turnTick' => $general->getTurnTick(),
'killTurn' => (int)$general->getVar('killturn'),
'mySet' => (int)$general->getVar('myset'),
]);
}
public function applyDB()
{
parent::applyDB();
if ($this->recordedPersistence) {
return;
}
$this->recordedPersistence = true;
$row = DB::db()->queryFirstRow(
'SELECT turntime, killturn, myset, last_turn FROM general WHERE no = %i',
$this->getGeneral()->getID(),
);
$this->record('persisted', [
'turnTick' => isset($row['turntime']) ? (int)$row['turntime'] : null,
'killTurn' => isset($row['killturn']) ? (int)$row['killturn'] : null,
'mySet' => isset($row['myset']) ? (int)$row['myset'] : null,
'lastTurn' => isset($row['last_turn']) ? comparisonJsonValue($row['last_turn']) : null,
]);
}
}
/** @return array<string, mixed> */
function comparisonRunFullTurnLifecycle(array $request): array
{
$actorGeneralId = $request['actorGeneralId'] ?? null;
$generalAction = $request['generalAction'] ?? null;
$nationAction = $request['nationAction'] ?? null;
$generalArgs = $request['generalArgs'] ?? [];
$nationArgs = $request['nationArgs'] ?? [];
if (!is_int($actorGeneralId) || $actorGeneralId < 1) {
throw new \InvalidArgumentException('actorGeneralId must be a positive integer');
}
if (!is_string($generalAction) || $generalAction === '') {
throw new \InvalidArgumentException('generalAction must be a non-empty string');
}
if (!is_string($nationAction) || $nationAction === '') {
throw new \InvalidArgumentException('nationAction must be a non-empty string');
}
if (!is_array($generalArgs) || !is_array($nationArgs)) {
throw new \InvalidArgumentException('generalArgs and nationArgs must be objects');
}
comparisonApplyTurnFixtureSetup($request['setup'] ?? null);
$db = DB::db();
$actor = $db->queryFirstRow(
'SELECT no, nation, officer_level, turntime FROM general WHERE no = %i',
$actorGeneralId,
);
if ($actor === null) {
throw new \RuntimeException('fixture actor is missing');
}
$nationId = (int)$actor['nation'];
$officerLevel = (int)$actor['officer_level'];
if ($nationId < 1 || $officerLevel < 5) {
throw new \InvalidArgumentException('full lifecycle fixture requires a nation officer actor');
}
$generalTurn = [
'general_id' => $actorGeneralId,
'turn_idx' => 0,
'action' => $generalAction,
'arg' => Json::encode($generalArgs),
'brief' => $generalAction,
];
$db->insertUpdate('general_turn', $generalTurn, $generalTurn);
$nationTurn = [
'nation_id' => $nationId,
'officer_level' => $officerLevel,
'turn_idx' => 0,
'action' => $nationAction,
'arg' => Json::encode($nationArgs),
'brief' => $nationAction,
];
$db->insertUpdate('nation_turn', $nationTurn, $nationTurn);
$observe = $request['observe'] ?? [];
if (!is_array($observe)) {
throw new \InvalidArgumentException('observe must be an object');
}
$snapshotRequest = ['observe' => $observe];
$before = comparisonTurnStateSnapshot($snapshotRequest);
$actorTurnTick = (int)$actor['turntime'];
$dueIds = array_map(
static fn(array $row): int => (int)$row['no'],
$db->query(
'SELECT no FROM general WHERE turntime < %i ORDER BY turntime ASC, no ASC',
$actorTurnTick + 1,
),
);
if ($dueIds !== [$actorGeneralId]) {
throw new \RuntimeException(
'full lifecycle fixture must isolate exactly the actor as due: ' . Json::encode($dueIds),
);
}
$game = KVStorage::getStorage($db, 'game_env');
[$year, $month] = $game->getValuesAsArray(['year', 'month']);
[$executionOver, $currentTurn] = TurnFullLifecycleComparisonHelper::executeGeneralCommandUntil(
$actorTurnTick + 1,
new \DateTimeImmutable('+1 hour'),
(int)$year,
(int)$month,
);
if ($executionOver || $currentTurn !== $actorTurnTick) {
throw new \RuntimeException('product lifecycle did not complete the isolated actor turn');
}
return [
'schemaVersion' => 1,
'engine' => 'ref',
'execution' => [
'kind' => 'general',
'actorGeneralId' => $actorGeneralId,
'action' => $generalAction,
'args' => $generalArgs,
'seedDomain' => 'generalCommand',
'outcome' => [
'entryPoint' => TurnExecutionHelper::class . '::executeGeneralCommandUntil',
'generalAction' => $generalAction,
'nationAction' => $nationAction,
'phases' => TurnFullLifecycleComparisonHelper::phases(),
],
],
'before' => $before,
'after' => comparisonTurnStateSnapshot($snapshotRequest),
// This runner proves lifecycle topology. Command RNG remains covered by
// the command-boundary matrices, which expose operation-level traces.
'rng' => [],
];
}
try {
$input = stream_get_contents(STDIN);
$request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($request)) {
throw new \InvalidArgumentException('request must be an object');
}
echo json_encode(
comparisonRunFullTurnLifecycle($request),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
exit(1);
}
+955
View File
@@ -0,0 +1,955 @@
<?php
declare(strict_types=1);
namespace sammo;
use sammo\Enums\RankColumn;
use sammo\Enums\GeneralStorKey;
use sammo\Enums\MessageType;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
chdir(dirname(__DIR__));
require_once 'lib.php';
require_once 'func.php';
/** @return list<int> */
function comparisonIntegerList(mixed $value, string $label): array
{
if ($value === null) {
return [];
}
if (!is_array($value)) {
throw new \InvalidArgumentException("{$label} must be an array");
}
$result = [];
foreach ($value as $entry) {
if (!is_int($entry) || $entry < 0) {
throw new \InvalidArgumentException("{$label} entries must be non-negative integers");
}
$result[$entry] = $entry;
}
ksort($result, SORT_NUMERIC);
return array_values($result);
}
function comparisonJsonValue(mixed $value): mixed
{
if (!is_string($value) || $value === '') {
return $value;
}
try {
$decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR);
// json_decode(..., true) collapses an empty JSON object into the same
// PHP array as `[]`, and json_encode would then report both as an
// array. Preserve the database container type for fail-closed graph
// comparison.
if ($decoded === [] && str_starts_with(ltrim($value), '{')) {
return (object)[];
}
return $decoded;
} catch (\JsonException) {
return $value;
}
}
function comparisonOptionalCode(mixed $value): ?string
{
if (!is_string($value) || $value === '' || $value === 'None') {
return null;
}
return $value;
}
function comparisonCommandInteger(mixed $value, string $label, ?int $fallback): ?int
{
if ($value === null) {
return $fallback;
}
if (!is_int($value)) {
throw new \InvalidArgumentException("{$label} must be an integer");
}
return $value;
}
function comparisonCommandBoolean(mixed $value, string $label): bool
{
if ($value === null || $value === false || $value === 0) {
return false;
}
if ($value === true || $value === 1) {
return true;
}
throw new \InvalidArgumentException("{$label} must be a boolean flag");
}
function comparisonCommandOptionalString(mixed $value, string $label): ?string
{
if ($value === null || $value === '') {
return null;
}
if (!is_string($value)) {
throw new \InvalidArgumentException("{$label} must be a string");
}
return $value;
}
/** @return array{turnSecond: int, turnFraction: int} */
function comparisonCommandTurnOffset(int $turnTick, int $baseTurnTick, int $ticksPerSecond): array
{
if ($ticksPerSecond <= 0) {
throw new \InvalidArgumentException('ticksPerSecond must be positive');
}
$offsetTicks = $turnTick - $baseTurnTick;
$turnSecond = intdiv($offsetTicks, $ticksPerSecond);
$remainingTicks = $offsetTicks % $ticksPerSecond;
if ($remainingTicks < 0) {
$turnSecond--;
$remainingTicks += $ticksPerSecond;
}
return [
'turnSecond' => $turnSecond,
'turnFraction' => intdiv($remainingTicks * 1_000_000, $ticksPerSecond),
];
}
/** @return list<array{cityId: int, remainingTurns: int}> */
function comparisonCommandSpyState(mixed $value): array
{
$decoded = comparisonJsonValue($value);
if ($decoded instanceof \stdClass) {
$decoded = (array)$decoded;
}
if ($decoded === null) {
return [];
}
if (!is_array($decoded)) {
throw new \InvalidArgumentException('nation.commandState.spy must be an object');
}
$result = [];
foreach ($decoded as $cityId => $remainingTurns) {
if ((!is_int($cityId) && (!is_string($cityId) || !ctype_digit($cityId))) || (int)$cityId < 1) {
throw new \InvalidArgumentException(
'nation.commandState.spy has an invalid city id: ' . (string)$cityId,
);
}
$result[] = [
'cityId' => (int)$cityId,
'remainingTurns' => comparisonCommandInteger(
$remainingTurns,
"nation.commandState.spy[{$cityId}]",
null,
),
];
}
usort($result, static fn(array $left, array $right): int => $left['cityId'] <=> $right['cityId']);
return $result;
}
/** @return list<array{generalId: int, actionName: string}> */
function comparisonGeneralCooldownSelectors(mixed $value, string $label): array
{
if ($value === null) {
return [];
}
if (!is_array($value)) {
throw new \InvalidArgumentException("{$label} must be an array");
}
$result = [];
foreach ($value as $entry) {
if (!is_array($entry)) {
throw new \InvalidArgumentException("{$label} entries must be objects");
}
$generalId = $entry['generalId'] ?? null;
$actionName = $entry['actionName'] ?? null;
if (!is_int($generalId) || $generalId < 1 || !is_string($actionName) || $actionName === '') {
throw new \InvalidArgumentException(
"{$label} entries require a positive generalId and non-empty actionName",
);
}
$result["{$generalId}:{$actionName}"] = [
'generalId' => $generalId,
'actionName' => $actionName,
];
}
ksort($result, SORT_STRING);
return array_values($result);
}
/** @return list<array{nationId: int, actionName: string}> */
function comparisonNationCooldownSelectors(mixed $value, string $label): array
{
if ($value === null) {
return [];
}
if (!is_array($value)) {
throw new \InvalidArgumentException("{$label} must be an array");
}
$result = [];
foreach ($value as $entry) {
if (!is_array($entry)) {
throw new \InvalidArgumentException("{$label} entries must be objects");
}
$nationId = $entry['nationId'] ?? null;
$actionName = $entry['actionName'] ?? null;
if (!is_int($nationId) || $nationId < 1 || !is_string($actionName) || $actionName === '') {
throw new \InvalidArgumentException(
"{$label} entries require a positive nationId and non-empty actionName",
);
}
$result["{$nationId}:{$actionName}"] = [
'nationId' => $nationId,
'actionName' => $actionName,
];
}
ksort($result, SORT_STRING);
return array_values($result);
}
/** @return list<array{fromNationId: int, toNationId: int}> */
function comparisonDiplomacyPairSelectors(mixed $value, string $label): array
{
if ($value === null) {
return [];
}
if (!is_array($value)) {
throw new \InvalidArgumentException("{$label} must be an array");
}
$result = [];
foreach ($value as $entry) {
if (!is_array($entry)) {
throw new \InvalidArgumentException("{$label} entries must be objects");
}
$fromNationId = $entry['fromNationId'] ?? null;
$toNationId = $entry['toNationId'] ?? null;
if (!is_int($fromNationId) || $fromNationId < 1 || !is_int($toNationId) || $toNationId < 1) {
throw new \InvalidArgumentException(
"{$label} entries require positive fromNationId and toNationId",
);
}
$result["{$fromNationId}:{$toNationId}"] = [
'fromNationId' => $fromNationId,
'toNationId' => $toNationId,
];
}
ksort($result, SORT_STRING);
return array_values($result);
}
/** @param array<string, mixed> $row */
function comparisonPickRow(array $row, array $mapping, array $jsonKeys = []): array
{
$result = [];
foreach ($mapping as $canonical => $legacy) {
if (!array_key_exists($legacy, $row)) {
continue;
}
$value = $row[$legacy];
if (in_array($legacy, $jsonKeys, true)) {
$value = comparisonJsonValue($value);
}
$result[$canonical] = $value;
}
return $result;
}
/** @param list<int> $ids */
function comparisonRowsById(string $table, string $idColumn, array $ids): array
{
$db = DB::db();
$rows = [];
foreach ($ids as $id) {
$row = $db->queryFirstRow(
"SELECT * FROM `{$table}` WHERE `{$idColumn}` = %i",
$id,
);
if ($row !== null) {
$rows[] = $row;
}
}
return $rows;
}
function comparisonTurnStateSnapshot(array $request): array
{
$db = DB::db();
$observe = $request['observe'] ?? [];
if (!is_array($observe)) {
throw new \InvalidArgumentException('observe must be an object');
}
$generalIds = comparisonIntegerList($observe['generalIds'] ?? [], 'generalIds');
$cityIds = comparisonIntegerList($observe['cityIds'] ?? [], 'cityIds');
$nationIds = comparisonIntegerList($observe['nationIds'] ?? [], 'nationIds');
$troopIds = comparisonIntegerList($observe['troopIds'] ?? [], 'troopIds');
foreach (['allGenerals', 'allCities', 'allNations', 'allTroops', 'includeRankMirrors'] as $allSelector) {
if (array_key_exists($allSelector, $observe) && !is_bool($observe[$allSelector])) {
throw new \InvalidArgumentException("{$allSelector} must be a boolean");
}
}
if (($observe['allGenerals'] ?? false) === true) {
$generalIds = array_map('intval', $db->queryFirstColumn('SELECT no FROM general ORDER BY no'));
}
if (($observe['allCities'] ?? false) === true) {
$cityIds = array_map('intval', $db->queryFirstColumn('SELECT city FROM city ORDER BY city'));
}
if (($observe['allNations'] ?? false) === true) {
$nationIds = array_map('intval', $db->queryFirstColumn('SELECT nation FROM nation ORDER BY nation'));
}
if (($observe['allTroops'] ?? false) === true) {
$troopIds = array_map('intval', $db->queryFirstColumn('SELECT troop_leader FROM troop ORDER BY troop_leader'));
} elseif ($generalIds !== []) {
$linkedTroopIds = array_map(
'intval',
$db->queryFirstColumn(
'SELECT DISTINCT troop FROM general WHERE no IN %li AND troop > 0 ORDER BY troop',
$generalIds,
),
);
$troopIds = array_values(array_unique([...$troopIds, ...$generalIds, ...$linkedTroopIds]));
sort($troopIds, SORT_NUMERIC);
}
$logAfterId = $observe['logAfterId'] ?? 0;
$messageAfterId = $observe['messageAfterId'] ?? 0;
$includeNationHistoryLogs = $observe['includeNationHistoryLogs'] ?? false;
$includeGlobalHistoryLogs = $observe['includeGlobalHistoryLogs'] ?? false;
$generalCooldownSelectors = comparisonGeneralCooldownSelectors(
$observe['generalCooldowns'] ?? [],
'generalCooldowns',
);
$nationCooldownSelectors = comparisonNationCooldownSelectors(
$observe['nationCooldowns'] ?? [],
'nationCooldowns',
);
$diplomacyPairSelectors = comparisonDiplomacyPairSelectors(
$observe['diplomacyPairs'] ?? [],
'diplomacyPairs',
);
if (
!is_int($logAfterId)
|| $logAfterId < 0
|| !is_int($messageAfterId)
|| $messageAfterId < 0
) {
throw new \InvalidArgumentException('logAfterId and messageAfterId must be non-negative integers');
}
if (!is_bool($includeNationHistoryLogs)) {
throw new \InvalidArgumentException('includeNationHistoryLogs must be a boolean');
}
if (!is_bool($includeGlobalHistoryLogs)) {
throw new \InvalidArgumentException('includeGlobalHistoryLogs must be a boolean');
}
$game = KVStorage::getStorage($db, 'game_env');
$game->resetCache();
$clock = GameClock::fromStorage($game);
// Use one logical instant for every general in this snapshot. Calling
// nowTick() inside the row loop could cross a realtime tick boundary and
// make unread state depend on query order.
$currentMessageTick = $clock->nowTick();
$worldValues = $game->getValues([
'year',
'month',
'turnterm',
'turntime',
'isunited',
'scenario',
'init_year',
'init_month',
'develcost',
'killturn',
]);
$baseTurnTick = Util::toInt($worldValues['turntime']);
$ticksPerSecond = $clock->ticksPerSecond();
$nextExecuteStorage = KVStorage::getStorage($db, 'next_execute');
$nextExecuteStorage->resetCache();
$generalCooldowns = array_map(
static function (array $selector) use ($nextExecuteStorage): array {
$key = "next_execute_{$selector['generalId']}_{$selector['actionName']}";
$value = $nextExecuteStorage->getValue($key);
return [
...$selector,
'nextAvailableTurn' => is_int($value) ? $value : null,
];
},
$generalCooldownSelectors,
);
$nationCooldowns = array_map(
static function (array $selector) use ($db): array {
$nationStorage = KVStorage::getStorage($db, $selector['nationId'], 'nation_env');
$nationStorage->resetCache();
$value = $nationStorage->getValue("next_execute_{$selector['actionName']}");
return [
...$selector,
'nextAvailableTurn' => is_int($value) ? $value : null,
];
},
$nationCooldownSelectors,
);
$generals = array_map(
static function (array $row) use ($clock, $currentMessageTick, $baseTurnTick, $ticksPerSecond): array {
$result = comparisonPickRow(
$row,
[
'id' => 'no',
'name' => 'name',
'nationId' => 'nation',
'cityId' => 'city',
'troopId' => 'troop',
'leadership' => 'leadership',
'strength' => 'strength',
'intelligence' => 'intel',
'experience' => 'experience',
'dedication' => 'dedication',
'expLevel' => 'explevel',
'officerLevel' => 'officer_level',
'officerCityId' => 'officer_city',
'belong' => 'belong',
'permission' => 'permission',
'betray' => 'betray',
'makeLimit' => 'makelimit',
'injury' => 'injury',
'gold' => 'gold',
'rice' => 'rice',
'crew' => 'crew',
'crewTypeId' => 'crewtype',
'train' => 'train',
'atmos' => 'atmos',
'age' => 'age',
'npcState' => 'npc',
'turnTime' => 'turntime',
'recentWarTime' => 'recent_war',
'lastTurn' => 'last_turn',
'meta' => 'aux',
'penalty' => 'penalty',
'leadershipExp' => 'leadership_exp',
'strengthExp' => 'strength_exp',
'intelExp' => 'intel_exp',
'dex1' => 'dex1',
'dex2' => 'dex2',
'dex3' => 'dex3',
'dex4' => 'dex4',
'dex5' => 'dex5',
'specAge' => 'specage',
'specAge2' => 'specage2',
'killTurn' => 'killturn',
'mySet' => 'myset',
'specialDomestic' => 'special',
'specialWar' => 'special2',
'personality' => 'personal',
'itemHorse' => 'horse',
'itemWeapon' => 'weapon',
'itemBook' => 'book',
'itemExtra' => 'item',
'picture' => 'picture',
'imageServer' => 'imgsvr',
],
['last_turn', 'aux', 'penalty'],
);
foreach ([
'specialDomestic',
'specialWar',
'personality',
'itemHorse',
'itemWeapon',
'itemBook',
'itemExtra',
] as $key) {
$result[$key] = comparisonOptionalCode($result[$key] ?? null);
}
$result['turnTime'] = $clock->formatTick(Util::toInt($row['turntime']), true);
$result['recentWarTime'] = $row['recent_war'] === null
? null
: $clock->formatTick(Util::toInt($row['recent_war']), true);
$meta = $result['meta'] ?? null;
$metaFields = $meta instanceof \stdClass ? get_object_vars($meta) : $meta;
if ($metaFields === null) {
$metaFields = [];
}
if (!is_array($metaFields)) {
throw new \InvalidArgumentException('general.meta must be an object');
}
$result['maxBelong'] = (int)($metaFields['max_belong'] ?? 0);
$result['dedLevel'] = comparisonCommandInteger(
$row['dedlevel'] ?? null,
'general.dedLevel',
0,
);
$result['affinity'] = comparisonCommandInteger(
$row['affinity'] ?? null,
'general.affinity',
null,
);
$result['bornYear'] = comparisonCommandInteger(
$row['bornyear'] ?? null,
'general.bornYear',
null,
);
$result['deadYear'] = comparisonCommandInteger(
$row['deadyear'] ?? null,
'general.deadYear',
null,
);
$result['npcMessage'] = comparisonCommandOptionalString(
$row['npcmsg'] ?? null,
'general.npcMessage',
);
$result['npcOriginalState'] = comparisonCommandInteger(
$row['npc_org'] ?? null,
'general.npcOriginalState',
0,
);
$result['turnTick'] = Util::toInt($row['turntime']);
$turnOffset = comparisonCommandTurnOffset($result['turnTick'], $baseTurnTick, $ticksPerSecond);
$result['turnSecond'] = $turnOffset['turnSecond'];
$result['turnFraction'] = $turnOffset['turnFraction'];
$result['commandState'] = [
'recruitmentArmType' => comparisonCommandInteger(
$metaFields['armType'] ?? null,
'general.commandState.recruitmentArmType',
null,
),
];
$ownerId = (int)($row['owner'] ?? 0);
$activeActionPoints = 0;
if ($ownerId > 0) {
$inheritanceStorage = KVStorage::getStorage(DB::db(), "inheritance_{$ownerId}");
$inheritanceStorage->resetCache();
$stored = $inheritanceStorage->getValue('active_action');
if (is_array($stored) && (is_int($stored[0] ?? null) || is_float($stored[0] ?? null))) {
$activeActionPoints = $stored[0];
}
}
$result['hasOwner'] = $ownerId > 0;
$result['ownerIdentity'] = $ownerId > 0 ? (string)$ownerId : null;
$generalId = (int)$row['no'];
$nationId = (int)$row['nation'];
$generalStorage = KVStorage::getStorage(DB::db(), "general_{$generalId}");
$generalStorage->resetCache();
[$latestReadDiplomacyMessageId, $latestReadPrivateMessageId] = $generalStorage->getValuesAsArray([
GeneralStorKey::latestReadDiplomacyMsg,
GeneralStorKey::latestReadPrivateMsg,
]);
$latestReadPrivateMessageId = (int)($latestReadPrivateMessageId ?? 0);
$latestReadDiplomacyMessageId = (int)($latestReadDiplomacyMessageId ?? 0);
$unreadPrivateCount = (int)(DB::db()->queryFirstField(
'SELECT COUNT(*) FROM message'
. ' WHERE mailbox = %i AND type = %s AND src <> %i AND id > %i AND valid_until > %i',
$generalId,
MessageType::private->value,
$generalId,
$latestReadPrivateMessageId,
$currentMessageTick,
) ?? 0);
$diplomacyMailbox = Message::MAILBOX_NATIONAL + $nationId;
$unreadDiplomacyCount = (int)(DB::db()->queryFirstField(
'SELECT COUNT(*) FROM message'
. ' WHERE mailbox = %i AND type = %s AND src <> %i AND id > %i AND valid_until > %i',
$diplomacyMailbox,
MessageType::diplomacy->value,
$diplomacyMailbox,
$latestReadDiplomacyMessageId,
$currentMessageTick,
) ?? 0);
$result['messageReadState'] = [
'unreadPrivateCount' => $unreadPrivateCount,
'unreadDiplomacyCount' => $unreadDiplomacyCount,
'hasUnreadMessage' => $unreadPrivateCount + $unreadDiplomacyCount > 0,
];
$result['inheritActiveActionPoints'] = $activeActionPoints;
return $result;
},
comparisonRowsById('general', 'no', $generalIds),
);
$rankTypes = array_map(
static fn(RankColumn $column): string => $column->value,
RankColumn::cases(),
);
$rankData = $generalIds === []
? []
: iterator_to_array($db->query(
'SELECT general_id AS generalId, nation_id AS nationId, `type`, `value`'
. ' FROM rank_data WHERE general_id IN %li AND `type` IN %ls'
. ' ORDER BY general_id, `type`',
$generalIds,
$rankTypes,
));
if (($observe['includeRankMirrors'] ?? false) === true) {
$rankMirrorFields = [
'experience' => 'experience',
'dedication' => 'dedication',
'dex1' => 'dex1',
'dex2' => 'dex2',
'dex3' => 'dex3',
'dex4' => 'dex4',
'dex5' => 'dex5',
];
foreach ($generals as $general) {
foreach ($rankMirrorFields as $type => $field) {
$rankData[] = [
'generalId' => (int)$general['id'],
'nationId' => (int)$general['nationId'],
'type' => $type,
'value' => (int)$general[$field],
];
}
}
usort(
$rankData,
static fn(array $left, array $right): int =>
[$left['generalId'], $left['type']] <=> [$right['generalId'], $right['type']],
);
}
$cities = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'city',
'name' => 'name',
'nationId' => 'nation',
'level' => 'level',
'population' => 'pop',
'populationMax' => 'pop_max',
'agriculture' => 'agri',
'agricultureMax' => 'agri_max',
'commerce' => 'comm',
'commerceMax' => 'comm_max',
'security' => 'secu',
'securityMax' => 'secu_max',
'supplyState' => 'supply',
'frontState' => 'front',
'defence' => 'def',
'defenceMax' => 'def_max',
'wall' => 'wall',
'wallMax' => 'wall_max',
'state' => 'state',
'term' => 'term',
'trust' => 'trust',
'trade' => 'trade',
'conflict' => 'conflict',
'officerSet' => 'officer_set',
],
['conflict'],
),
comparisonRowsById('city', 'city', $cityIds),
);
$nations = array_map(
static function (array $row) use ($db): array {
$projected = comparisonPickRow(
$row,
[
'id' => 'nation',
'name' => 'name',
'color' => 'color',
'capitalCityId' => 'capital',
'gold' => 'gold',
'rice' => 'rice',
'tech' => 'tech',
'level' => 'level',
'typeCode' => 'type',
'generalCount' => 'gennum',
'power' => 'power',
'war' => 'war',
'diplomacyLimit' => 'surlimit',
'capitalRevision' => 'capset',
'strategicCommandLimit' => 'strategic_cmd_limit',
'meta' => 'aux',
],
['aux'],
);
$meta = $projected['meta'] ?? null;
$metaFields = $meta instanceof \stdClass ? get_object_vars($meta) : $meta;
if ($metaFields === null) {
$metaFields = [];
}
if (!is_array($metaFields)) {
throw new \InvalidArgumentException('nation.meta must be an object');
}
$nationStor = KVStorage::getStorage($db, (int)$row['nation'], 'nation_env');
$receivedAssist = $nationStor->getValue('recv_assist');
if (is_array($receivedAssist) && $receivedAssist !== []) {
$metaFields['recv_assist'] = $receivedAssist;
$projected['meta'] = $metaFields;
}
$respondedAssist = $nationStor->getValue('resp_assist');
if (is_array($respondedAssist) && $respondedAssist !== []) {
$metaFields['resp_assist'] = $respondedAssist;
$projected['meta'] = $metaFields;
}
$projected['commandState'] = [
'flagChangesRemaining' => comparisonCommandInteger(
$metaFields['can_국기변경'] ?? null,
'nation.commandState.flagChangesRemaining',
0,
),
'randomCapitalMovesRemaining' => comparisonCommandInteger(
$metaFields['can_무작위수도이전'] ?? null,
'nation.commandState.randomCapitalMovesRemaining',
0,
),
'spy' => comparisonCommandSpyState($row['spy'] ?? null),
'collapsed' => comparisonCommandBoolean(
$metaFields['collapsed'] ?? null,
'nation.commandState.collapsed',
),
'rate' => comparisonCommandInteger(
$row['rate'] ?? null,
'nation.commandState.rate',
0,
),
'bill' => comparisonCommandInteger(
$row['bill'] ?? null,
'nation.commandState.bill',
0,
),
'secretLimit' => comparisonCommandInteger(
$row['secretlimit'] ?? null,
'nation.commandState.secretLimit',
3,
),
];
return $projected;
},
comparisonRowsById('nation', 'nation', $nationIds),
);
$troops = array_map(
static fn(array $row): array => comparisonPickRow(
$row,
[
'id' => 'troop_leader',
'nationId' => 'nation',
'name' => 'name',
],
),
comparisonRowsById('troop', 'troop_leader', $troopIds),
);
$diplomacyPairs = [];
foreach ($nationIds as $fromNationId) {
foreach ($nationIds as $toNationId) {
if ($fromNationId === $toNationId) {
continue;
}
$diplomacyPairs["{$fromNationId}:{$toNationId}"] = [
'fromNationId' => $fromNationId,
'toNationId' => $toNationId,
];
}
}
foreach ($diplomacyPairSelectors as $pair) {
$diplomacyPairs["{$pair['fromNationId']}:{$pair['toNationId']}"] = $pair;
}
ksort($diplomacyPairs, SORT_STRING);
$diplomacy = [];
foreach ($diplomacyPairs as $pair) {
$row = $db->queryFirstRow(
'SELECT me, you, state, term, dead FROM diplomacy WHERE me = %i AND you = %i',
$pair['fromNationId'],
$pair['toNationId'],
);
if ($row !== null) {
$diplomacy[] = comparisonPickRow($row, [
'fromNationId' => 'me',
'toNationId' => 'you',
'state' => 'state',
'term' => 'term',
'dead' => 'dead',
]);
}
}
$generalTurns = [];
foreach ($generalIds as $generalId) {
foreach ($db->query(
'SELECT general_id, turn_idx, action, arg FROM general_turn WHERE general_id = %i ORDER BY turn_idx',
$generalId,
) as $row) {
$generalTurns[] = comparisonPickRow(
$row,
['generalId' => 'general_id', 'turnIndex' => 'turn_idx', 'action' => 'action', 'args' => 'arg'],
['arg'],
);
}
}
$nationTurns = [];
foreach ($nationIds as $nationId) {
foreach ($db->query(
'SELECT nation_id, officer_level, turn_idx, action, arg FROM nation_turn WHERE nation_id = %i ORDER BY officer_level, turn_idx',
$nationId,
) as $row) {
$nationTurns[] = comparisonPickRow(
$row,
[
'nationId' => 'nation_id',
'officerLevel' => 'officer_level',
'turnIndex' => 'turn_idx',
'action' => 'action',
'args' => 'arg',
],
['arg'],
);
}
}
$logs = [];
if ($generalIds !== []) {
foreach ($db->query(
'SELECT id, general_id, log_type, year, month, text FROM general_record WHERE id > %i ORDER BY id',
$logAfterId,
) as $row) {
if ((int)$row['general_id'] !== 0 && !in_array((int)$row['general_id'], $generalIds, true)) {
continue;
}
$generalId = (int)$row['general_id'];
$category = (string)$row['log_type'];
if ($category === 'battle') {
$category = 'battle_detail';
} elseif ($generalId === 0) {
$category = 'summary';
}
$logs[] = comparisonPickRow($row, [
'id' => 'id',
'generalId' => 'general_id',
'year' => 'year',
'month' => 'month',
'text' => 'text',
]) + [
'scope' => $generalId === 0 ? 'system' : 'general',
'category' => $category,
'nationId' => null,
];
}
}
if ($includeNationHistoryLogs && $nationIds !== []) {
foreach ($db->query(
'SELECT id, nation_id, year, month, text FROM world_history WHERE nation_id IN %li ORDER BY id',
$nationIds,
) as $row) {
$logs[] = comparisonPickRow($row, [
'id' => 'id',
'nationId' => 'nation_id',
'year' => 'year',
'month' => 'month',
'text' => 'text',
]) + [
'scope' => 'nation',
'category' => 'history',
'generalId' => null,
];
}
}
if ($includeGlobalHistoryLogs) {
foreach ($db->query(
'SELECT id, nation_id, year, month, text FROM world_history WHERE nation_id = 0 ORDER BY id',
) as $row) {
$logs[] = comparisonPickRow($row, [
'id' => 'id',
'nationId' => 'nation_id',
'year' => 'year',
'month' => 'month',
'text' => 'text',
]) + [
'scope' => 'system',
'category' => 'history',
'generalId' => null,
];
}
}
$messages = array_map(
static function (array $row) use ($clock): array {
$result = comparisonPickRow(
$row,
[
'id' => 'id',
'mailbox' => 'mailbox',
'type' => 'type',
'sourceId' => 'src',
'destinationId' => 'dest',
'createdAt' => 'time',
'validUntil' => 'valid_until',
'payload' => 'message',
],
['message'],
);
$result['createdAt'] = $clock->formatTick(Util::toInt($row['time']), true);
$validUntilTick = Util::toInt($row['valid_until']);
$result['validUntil'] = $validUntilTick === GameClock::MAX_SAFE_TICK
? 'infinite'
: $clock->formatTick($validUntilTick, true);
return $result;
},
$db->query(
'SELECT id, mailbox, type, src, dest, time, valid_until, message'
. ' FROM message WHERE id > %i ORDER BY id',
$messageAfterId,
),
);
return [
'schemaVersion' => 1,
'engine' => 'ref',
'world' => [
'year' => (int)$worldValues['year'],
'month' => (int)$worldValues['month'],
'tickMinutes' => (int)$worldValues['turnterm'],
'lastTurnTick' => $baseTurnTick,
'turnTime' => $clock->formatTick(Util::toInt($worldValues['turntime']), true),
'gameNow' => $clock->formatTick($currentMessageTick, true),
'isUnited' => (int)$worldValues['isunited'],
'scenarioId' => (int)$worldValues['scenario'],
'initYear' => (int)$worldValues['init_year'],
'initMonth' => (int)$worldValues['init_month'],
'develCost' => (int)$worldValues['develcost'],
'killTurn' => (int)$worldValues['killturn'],
'generalCooldowns' => $generalCooldowns,
'nationCooldowns' => $nationCooldowns,
],
'generals' => $generals,
'rankData' => $rankData,
'cities' => $cities,
'nations' => $nations,
'troops' => $troops,
'diplomacy' => $diplomacy,
'generalTurns' => $generalTurns,
'nationTurns' => $nationTurns,
'logs' => $logs,
'messages' => $messages,
'watermarks' => [
'logId' => (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM general_record') ?? 0),
'historyLogId' => (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM world_history') ?? 0),
'messageId' => (int)($db->queryFirstField('SELECT COALESCE(MAX(id), 0) FROM message') ?? 0),
],
];
}
function comparisonTurnStateSnapshotMain(): void
{
try {
$input = stream_get_contents(STDIN);
$request = json_decode($input === '' ? '{}' : $input, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($request)) {
throw new \InvalidArgumentException('request must be an object');
}
echo json_encode(
comparisonTurnStateSnapshot($request),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION,
), PHP_EOL;
} catch (\Throwable $throwable) {
fwrite(STDERR, $throwable::class . ': ' . $throwable->getMessage() . PHP_EOL);
exit(1);
}
}
if (realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
comparisonTurnStateSnapshotMain();
}
+1 -1
View File
@@ -1734,7 +1734,7 @@ function deleteNation(General $lord, bool $applyDB): array
$nationGeneralList = General::createObjListFromDB( $nationGeneralList = General::createObjListFromDB(
$db->queryFirstColumn( $db->queryFirstColumn(
'SELECT `no` FROM general WHERE nation=%i AND no != %i', 'SELECT `no` FROM general WHERE nation=%i AND no != %i ORDER BY no ASC',
$nationID, $nationID,
$lordID $lordID
), ),
+42 -2
View File
@@ -319,7 +319,38 @@ function postUpdateMonthly(RandUtil $rng)
//약간의 랜덤치 부여 (95% ~ 105%) //약간의 랜덤치 부여 (95% ~ 105%)
$nation['power'] = Util::round($nation['power'] * $rng->nextRange(0.95, 1.05)); $powerMultiplier = $rng->nextRange(0.95, 1.05);
$basePower = $nation['power'];
$nation['power'] = Util::round($basePower * $powerMultiplier);
$traceNationIDs = getenv('REF_TRACE_NATION_POWER_IDS');
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& is_string($traceNationIDs)
&& in_array((string) $nationID, explode(',', $traceNationIDs), true)
) {
$traceComponents = $db->queryFirstRow('SELECT
((A.gold + A.rice) + (SELECT SUM(gold + rice) FROM general WHERE nation = A.nation)) / 100 AS resourcePowerRaw,
ROUND(((A.gold + A.rice) + (SELECT SUM(gold + rice) FROM general WHERE nation = A.nation)) / 100) AS resourcePower,
A.tech AS techPower,
(SELECT SUM(pop) * SUM(pop + agri + comm + secu + wall + def) / SUM(pop_max + agri_max + comm_max + secu_max + wall_max + def_max) / 100 FROM city WHERE nation = A.nation AND supply = 1) AS cityPowerRaw,
(SELECT ROUND(SUM(pop) * SUM(pop + agri + comm + secu + wall + def) / SUM(pop_max + agri_max + comm_max + secu_max + wall_max + def_max) / 100) FROM city WHERE nation = A.nation AND supply = 1) AS cityPower,
(SELECT SUM((ra.value + 1000) / (rb.value + 1000) * (CASE WHEN g.npc < 2 THEN 1.2 ELSE 1 END) * (CASE WHEN g.leadership >= 40 THEN g.leadership ELSE 0 END) * 2 + (SQRT(g.intel * g.strength) * 2 + g.leadership / 2) / 2)
FROM general g
LEFT JOIN rank_data ra ON g.no = ra.general_id AND ra.type = \'killcrew_person\'
LEFT JOIN rank_data rb ON g.no = rb.general_id AND rb.type = \'deathcrew_person\'
WHERE g.nation = A.nation) AS generalPower,
(SELECT SUM(experience + dedication) / 100 FROM general WHERE nation = A.nation) AS experiencePowerRaw,
(SELECT ROUND(SUM(experience + dedication) / 100) FROM general WHERE nation = A.nation) AS experiencePower
FROM nation A WHERE A.nation = %i', $nationID) ?? [];
fwrite(STDOUT, 'NATION_POWER_TRACE ' . Json::encode([
'nationId' => $nationID,
...$traceComponents,
'basePower' => $basePower,
'multiplier' => $powerMultiplier,
'power' => $nation['power'],
]) . PHP_EOL);
}
$powerValues['maxPower'] = max($powerValues['maxPower'] ?? 0, $nation['power']); $powerValues['maxPower'] = max($powerValues['maxPower'] ?? 0, $nation['power']);
$powerValues['maxCrew'] = max($powerValues['maxCrew'] ?? 0, Util::toInt($nation['totalCrew'])); $powerValues['maxCrew'] = max($powerValues['maxCrew'] ?? 0, Util::toInt($nation['totalCrew']));
@@ -769,6 +800,7 @@ function checkEmperior()
$gameStor->isunited = 2; $gameStor->isunited = 2;
$gameStor->refreshLimit = $gameStor->refreshLimit * 100; $gameStor->refreshLimit = $gameStor->refreshLimit * 100;
AutoResetProgress::record($gameStor);
foreach ($db->queryFirstColumn('SELECT no FROM general WHERE npc<2 AND age>=%i', GameConst::$minPushHallAge) as $hallGeneralNo) { foreach ($db->queryFirstColumn('SELECT no FROM general WHERE npc<2 AND age>=%i', GameConst::$minPushHallAge) as $hallGeneralNo) {
CheckHall($hallGeneralNo); CheckHall($hallGeneralNo);
@@ -780,7 +812,7 @@ function checkEmperior()
$chiefs = Util::convertArrayToDict( $chiefs = Util::convertArrayToDict(
$db->query( $db->query(
'SELECT no,npc,name,picture,belong,officer_level FROM general WHERE nation=%i AND officer_level >= 5', 'SELECT no,npc,name,picture,imgsvr,belong,officer_level FROM general WHERE nation=%i AND officer_level >= 5',
$nationID $nationID
), ),
'officer_level' 'officer_level'
@@ -893,20 +925,28 @@ function checkEmperior()
'rice' => $nation['rice'], 'rice' => $nation['rice'],
'l12name' => $chiefs[12]['name'], 'l12name' => $chiefs[12]['name'],
'l12pic' => $chiefs[12]['picture'], 'l12pic' => $chiefs[12]['picture'],
'l12imgsvr' => $chiefs[12]['imgsvr'],
'l11name' => $chiefs[11]['name'], 'l11name' => $chiefs[11]['name'],
'l11pic' => $chiefs[11]['picture'], 'l11pic' => $chiefs[11]['picture'],
'l11imgsvr' => $chiefs[11]['imgsvr'],
'l10name' => $chiefs[10]['name'], 'l10name' => $chiefs[10]['name'],
'l10pic' => $chiefs[10]['picture'], 'l10pic' => $chiefs[10]['picture'],
'l10imgsvr' => $chiefs[10]['imgsvr'],
'l9name' => $chiefs[9]['name'], 'l9name' => $chiefs[9]['name'],
'l9pic' => $chiefs[9]['picture'], 'l9pic' => $chiefs[9]['picture'],
'l9imgsvr' => $chiefs[9]['imgsvr'],
'l8name' => $chiefs[8]['name'], 'l8name' => $chiefs[8]['name'],
'l8pic' => $chiefs[8]['picture'], 'l8pic' => $chiefs[8]['picture'],
'l8imgsvr' => $chiefs[8]['imgsvr'],
'l7name' => $chiefs[7]['name'], 'l7name' => $chiefs[7]['name'],
'l7pic' => $chiefs[7]['picture'], 'l7pic' => $chiefs[7]['picture'],
'l7imgsvr' => $chiefs[7]['imgsvr'],
'l6name' => $chiefs[6]['name'], 'l6name' => $chiefs[6]['name'],
'l6pic' => $chiefs[6]['picture'], 'l6pic' => $chiefs[6]['picture'],
'l6imgsvr' => $chiefs[6]['imgsvr'],
'l5name' => $chiefs[5]['name'], 'l5name' => $chiefs[5]['name'],
'l5pic' => $chiefs[5]['picture'], 'l5pic' => $chiefs[5]['picture'],
'l5imgsvr' => $chiefs[5]['imgsvr'],
'tiger' => $tigerstr, 'tiger' => $tigerstr,
'eagle' => $eaglestr, 'eagle' => $eaglestr,
'gen' => $gen, 'gen' => $gen,
+1 -1
View File
@@ -367,7 +367,7 @@ function disaster(RandUtil $rng) {
[$queryColumns,,] = General::mergeQueryColumn(); [$queryColumns,,] = General::mergeQueryColumn();
$generalListByCity = Util::arrayGroupBy( $generalListByCity = Util::arrayGroupBy(
$db->query( $db->query(
'SELECT %l FROM general WHERE city IN %li', 'SELECT %l FROM general WHERE city IN %li ORDER BY city, no',
Util::formatListOfBackticks($queryColumns), Util::formatListOfBackticks($queryColumns),
Util::squeezeFromArray($targetCityList, 'city') Util::squeezeFromArray($targetCityList, 'city')
), ),
+9
View File
@@ -15,6 +15,15 @@ function calcTournamentTerm(int $turnTerm): int{
function processTournament() function processTournament()
{ {
if (
getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& getenv('REF_DETERMINISTIC_TOURNAMENT_MODE') === 'hold'
) {
// Tournament randomness is independent from the monthly hidden-seed
// stream. Keep it out of deterministic monthly comparisons.
return;
}
$db = DB::db(); $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$clock = GameClock::fromStorage($gameStor); $clock = GameClock::fromStorage($gameStor);
+11 -29
View File
@@ -52,14 +52,9 @@ if($token && $refresh){
if($now >= $pickMoreFrom){ if($now >= $pickMoreFrom){
$oldPickResult = Json::decode($token['pick_result']); $oldPickResult = Json::decode($token['pick_result']);
foreach($keepResult as $keepId){ $keepSelection = NpcPossessionSelector::applyKeep($oldPickResult, $keepResult);
if(\key_exists($keepId, $oldPickResult) && $oldPickResult[$keepId]['keepCnt'] > 0){ $pickResult = $keepSelection['picked'];
$pickResult[$keepId] = $oldPickResult[$keepId]; if($keepSelection['cancelled']){
$pickResult[$keepId]['keepCnt']-=1;
}
}
if(count($pickResult) == count($oldPickResult)){
$refresh = false; $refresh = false;
} }
} }
@@ -94,35 +89,22 @@ foreach($db->query('SELECT `no`, `name`, leadership, strength, intel, nation, im
$general['personal'] = buildPersonalityClass($general['personal'])->getName(); $general['personal'] = buildPersonalityClass($general['personal'])->getName();
$general['nation'] = getNationStaticInfo($general['nation'])['name']; $general['nation'] = getNationStaticInfo($general['nation'])['name'];
$candidates[$general['no']] = $general + ['keepCnt'=>KEEP_CNT]; $candidates[$general['no']] = $general + ['keepCnt'=>KEEP_CNT];
$allStat = $general['leadership'] + $general['strength'] + $general['intel']; $weight[$general['no']] = NpcPossessionSelector::weight($general);
$weight[$general['no']] = pow($allStat, 1.5);
} }
foreach($db->queryFirstColumn('SELECT pick_result FROM select_npc_token WHERE `owner`!=%i AND valid_until >=%i', $userID, $now) as $reserved){ NpcPossessionSelector::removeReserved(
$reserved = Json::decode($reserved); $candidates,
foreach(array_keys($reserved) as $reservedNPC){ $weight,
if(key_exists($reservedNPC, $weight)){ $db->queryFirstColumn('SELECT pick_result FROM select_npc_token WHERE `owner`!=%i AND valid_until >=%i', $userID, $now),
unset($candidates[$reservedNPC]); );
unset($weight[$reservedNPC]);
}
}
}
$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( $rng = new RandUtil(new LiteHashDRBG(NpcPossessionSelector::buildSeed(
UniqueConst::$hiddenSeed, UniqueConst::$hiddenSeed,
'SelectNPCToken',
$userID, $userID,
$now, $now,
))); )));
$pickLimit = min(count($candidates), 5); $pickResult = NpcPossessionSelector::select($candidates, $weight, $pickResult, $rng);
while(count($pickResult) < $pickLimit){
$generalID = $rng->choiceUsingWeight($weight);
if(!key_exists($generalID, $pickResult)){
$pickResult[$generalID] = $candidates[$generalID];
}
}
$newNonce = random_int(0, 0xfffffff); $newNonce = random_int(0, 0xfffffff);
+9 -1
View File
@@ -200,6 +200,14 @@ if($reserve_open){
} }
try{ try{
$installTime = TimeUtil::format(GameClock::readWallTime(), false);
if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1') {
$comparisonInstallAt = getenv('REF_INSTALL_AT');
if (!is_string($comparisonInstallAt) || $comparisonInstallAt === '') {
throw new \RuntimeException('REF_INSTALL_AT is required for deterministic comparison install');
}
$installTime = (new \DateTimeImmutable($comparisonInstallAt))->format('Y-m-d H:i:s');
}
Json::die(ResetHelper::buildScenario( Json::die(ResetHelper::buildScenario(
$turnterm, $turnterm,
$sync, $sync,
@@ -211,7 +219,7 @@ try{
$show_img_level, $show_img_level,
!!$tournament_trig, !!$tournament_trig,
$join_mode, $join_mode,
TimeUtil::format(GameClock::readWallTime(), false), $installTime,
$autorun_user $autorun_user
)); ));
} }
+13 -5
View File
@@ -19,7 +19,6 @@ if(!class_exists('\\sammo\\DB')){
$db = DB::db(); $db = DB::db();
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$clock = GameClock::fromStorage($gameStor);
if(file_exists(__DIR__.'/.htaccess')){ if(file_exists(__DIR__.'/.htaccess')){
$reserved = $db->queryFirstRow( $reserved = $db->queryFirstRow(
@@ -71,15 +70,24 @@ if(file_exists(__DIR__.'/.htaccess')){
//TODO: 천통시에도 예약 오픈 알림이 필요..? //TODO: 천통시에도 예약 오픈 알림이 필요..?
$usesLogicalClock = GameClock::isInitialized($gameStor);
$admin = $gameStor->getValues(['isunited', 'npcmode', 'year', 'month', 'scenario', 'scenario_text', 'maxgeneral', 'turnterm', 'opentime', 'turntime', 'join_mode', 'fiction', 'block_general_create', 'autorun_user']); $admin = $gameStor->getValues(['isunited', 'npcmode', 'year', 'month', 'scenario', 'scenario_text', 'maxgeneral', 'turnterm', 'opentime', 'turntime', 'join_mode', 'fiction', 'block_general_create', 'autorun_user']);
$admin['maxUserCnt'] = $admin['maxgeneral']; $admin['maxUserCnt'] = $admin['maxgeneral'];
$admin['npcMode'] = $admin['npcmode']; $admin['npcMode'] = $admin['npcmode'];
$admin['turnTerm'] = $admin['turnterm']; $admin['turnTerm'] = $admin['turnterm'];
$admin['isUnited'] = $admin['isunited']; $admin['isUnited'] = $admin['isunited'];
$admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']); if($usesLogicalClock){
$admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime'])); $clock = GameClock::fromStorage($gameStor);
$admin['starttime'] = substr($admin['opentime'], 5, 11); $admin['isOpen'] = $clock->nowTick() >= Util::toInt($admin['opentime']);
$admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11); $admin['opentime'] = $clock->formatTick(Util::toInt($admin['opentime']));
$admin['starttime'] = substr($admin['opentime'], 5, 11);
$admin['turntime'] = substr($clock->formatTick(Util::toInt($admin['turntime'])), 5, 11);
}
else{
$admin['isOpen'] = new \DateTimeImmutable((string)$admin['opentime']) <= GameClock::readWallTime();
$admin['starttime'] = substr((string)$admin['opentime'], 5, 11);
$admin['turntime'] = substr((string)$admin['turntime'], 5, 11);
}
unset($admin['npcmode']); unset($admin['npcmode']);
unset($admin['maxgeneral']); unset($admin['maxgeneral']);
unset($admin['turnterm']); unset($admin['turnterm']);
+200 -3
View File
@@ -37,15 +37,44 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke
$city = new WarUnitCity($rng, $rawDefenderCity, $rawDefenderNation, $year, $month, $startYear); $city = new WarUnitCity($rng, $rawDefenderCity, $rawDefenderNation, $year, $month, $startYear);
$defenderCityGeneralIDList = $db->queryFirstColumn('SELECT no FROM general WHERE nation=%i AND city=%i AND nation!=0', $city->getVar('nation'), $city->getVar('city')); $defenderCityGeneralIDList = $db->queryFirstColumn('SELECT no FROM general WHERE nation=%i AND city=%i AND nation!=0 ORDER BY no', $city->getVar('nation'), $city->getVar('city'));
$defenderCityGeneralList = General::createObjListFromDB($defenderCityGeneralIDList, null); $defenderCityGeneralList = General::createObjListFromDB($defenderCityGeneralIDList, null);
$captureBattleFixture =
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& getenv('REF_BATTLE_FIXTURE_TRACE') === '1';
if ($captureBattleFixture) {
require_once __DIR__ . '/compare/battle_fixture_capture.php';
fwrite(STDOUT, 'AI_WAR_FIXTURE_REF ' . Json::encode(comparisonBuildBattleFixture(
$warSeed,
(int)$year,
(int)$month,
(int)$startYear,
$attackerGeneral,
$rawAttackerNation,
$defenderCityGeneralList,
$rawDefenderCity,
$rawDefenderNation,
)) . "\n");
}
/** @var WarUnit[] */ /** @var WarUnit[] */
$defenderList = []; $defenderList = [];
$defenderCandidateTrace = [];
foreach($defenderCityGeneralList as $defenderGeneral){ foreach($defenderCityGeneralList as $defenderGeneral){
$defenderGeneral->setRawCity($rawDefenderCity); $defenderGeneral->setRawCity($rawDefenderCity);
$defenderCandidate = new WarUnitGeneral($rng, $defenderGeneral, $rawDefenderNation, false); $defenderCandidate = new WarUnitGeneral($rng, $defenderGeneral, $rawDefenderNation, false);
if(extractBattleOrder($defenderCandidate, $attacker) <= 0){ $defenderOrder = extractBattleOrder($defenderCandidate, $attacker);
$defenderCandidateTrace[] = [
'id' => $defenderGeneral->getID(),
'crew' => $defenderGeneral->getVar('crew'),
'crewtype' => $defenderGeneral->getVar('crewtype'),
'train' => $defenderGeneral->getVar('train'),
'atmos' => $defenderGeneral->getVar('atmos'),
'order' => $defenderOrder,
];
if($defenderOrder <= 0){
continue; continue;
} }
@@ -56,6 +85,14 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke
$defenderList[] = $city; $defenderList[] = $city;
} }
$summarizeDefenderOrder = static function (WarUnit $unit) use ($attacker): array {
return [
'id' => $unit instanceof WarUnitGeneral ? $unit->getGeneral()->getID() : 0,
'order' => extractBattleOrder($unit, $attacker),
];
};
$defenderOrderBeforeSort = array_map($summarizeDefenderOrder, $defenderList);
usort($defenderList, function (WarUnit $lhs, WarUnit $rhs) use ($attacker) { usort($defenderList, function (WarUnit $lhs, WarUnit $rhs) use ($attacker) {
return - (extractBattleOrder($lhs, $attacker) <=> extractBattleOrder($rhs, $attacker)); return - (extractBattleOrder($lhs, $attacker) <=> extractBattleOrder($rhs, $attacker));
}); });
@@ -85,7 +122,39 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke
return $nextDefender; return $nextDefender;
}; };
$conquerCity = processWar_NG($warSeed, $attacker, $getNextDefender, $city); $traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS');
$traceGeneralIdList = is_string($traceGeneralIds) ? explode(',', $traceGeneralIds) : [];
$traceDefender = false;
foreach ($defenderCityGeneralList as $defenderGeneral) {
if (in_array((string)$defenderGeneral->getID(), $traceGeneralIdList, true)) {
$traceDefender = true;
break;
}
}
$warTrace =
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& is_string($traceGeneralIds)
&& (in_array((string)$attackerGeneral->getID(), $traceGeneralIdList, true) || $traceDefender)
? static function (array $event) use ($attackerGeneral): void {
fwrite(STDOUT, 'AI_WAR_TRACE ' . Json::encode([
'generalId' => $attackerGeneral->getID(),
'event' => $event,
]) . "\n");
}
: null;
if ($warTrace !== null) {
fwrite(STDOUT, 'AI_WAR_DEFENDER_CANDIDATE_TRACE ' . Json::encode([
'generalId' => $attackerGeneral->getID(),
'candidates' => $defenderCandidateTrace,
]) . "\n");
fwrite(STDOUT, 'AI_WAR_DEFENDER_ORDER_TRACE ' . Json::encode([
'generalId' => $attackerGeneral->getID(),
'before' => $defenderOrderBeforeSort,
'after' => array_map($summarizeDefenderOrder, $defenderList),
]) . "\n");
}
$conquerCity = processWar_NG($warSeed, $attacker, $getNextDefender, $city, $warTrace);
$attacker->applyDB($db); $attacker->applyDB($db);
@@ -157,6 +226,32 @@ function processWar(string $warSeed, General $attackerGeneral, array $rawAttacke
$defenderIncTech /= 4; $defenderIncTech /= 4;
} }
$traceNationIds = array_filter(explode(',', (string) getenv('REF_WAR_TECH_TRACE_NATION_IDS')), 'strlen');
if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1') {
foreach ([
['side' => 'attacker', 'nation' => $rawAttackerNation, 'baseGain' => $attacker->getDead() * 0.012, 'gain' => $attackerIncTech, 'effective' => $attackerGenCnt_eff],
['side' => 'defender', 'nation' => $rawDefenderNation, 'baseGain' => $attacker->getKilled() * 0.009, 'gain' => $defenderIncTech, 'effective' => $defenderGenCnt_eff],
] as $traceEntry) {
if (!in_array((string) $traceEntry['nation']['nation'], $traceNationIds, true)) {
continue;
}
$divisor = max(GameConst::$initialNationGenLimit, $traceEntry['nation']['gennum']);
fwrite(STDOUT, 'WAR_TECH_TRACE ' . Json::encode([
'engine' => 'ref',
'nationId' => $traceEntry['nation']['nation'],
'side' => $traceEntry['side'],
'currentTech' => $traceEntry['nation']['tech'],
'baseGain' => $traceEntry['baseGain'],
'gain' => $traceEntry['gain'],
'total' => $traceEntry['nation']['gennum'],
'effective' => $traceEntry['effective'],
'divisor' => $divisor,
'delta' => $traceEntry['gain'] / $divisor,
'attackerGeneralId' => $attacker->getGeneral()->getID(),
]) . "\n");
}
}
$updateAttackerNation['tech'] = $db->sqleval( $updateAttackerNation['tech'] = $db->sqleval(
'tech + %d', 'tech + %d',
$attackerIncTech / max(GameConst::$initialNationGenLimit, $rawAttackerNation['gennum']) $attackerIncTech / max(GameConst::$initialNationGenLimit, $rawAttackerNation['gennum'])
@@ -230,6 +325,7 @@ function processWar_NG(
WarUnitGeneral $attacker, WarUnitGeneral $attacker,
callable $getNextDefender, callable $getNextDefender,
WarUnitCity $city, WarUnitCity $city,
?callable $trace = null,
): bool { ): bool {
$templates = new \League\Plates\Engine(__DIR__ . '/templates'); $templates = new \League\Plates\Engine(__DIR__ . '/templates');
@@ -245,6 +341,26 @@ function processWar_NG(
/** @var WarUnit */ /** @var WarUnit */
$defender = ($getNextDefender)(null, true); $defender = ($getNextDefender)(null, true);
$conquerCity = false; $conquerCity = false;
$traceSeq = 0;
$emitTrace = function (string $event, ?WarUnit $currentDefender, array $details = []) use (
$trace,
&$traceSeq,
$attacker,
$city
): void {
if ($trace === null) {
return;
}
$trace([
'seq' => $traceSeq++,
'event' => $event,
'attacker' => buildWarTraceUnitSnapshot($attacker),
'defender' => $currentDefender === null ? null : buildWarTraceUnitSnapshot($currentDefender),
'city' => buildWarTraceUnitSnapshot($city),
'details' => $details,
]);
};
$emitTrace('battle_start', $defender, ['seed' => $warSeed]);
$josaRo = JosaUtil::pick($city->getName(), '로'); $josaRo = JosaUtil::pick($city->getName(), '로');
$josaYi = JosaUtil::pick($attacker->getName(), '이'); $josaYi = JosaUtil::pick($attacker->getName(), '이');
@@ -277,6 +393,7 @@ function processWar_NG(
$logger->pushGlobalHistoryLog("<M><b>【패퇴】</b></><D><b>{$defender->getNationVar('name')}</b></>{$josaYi} 병량 부족으로 <G><b>{$defender->getName()}</b></>{$josaUl} 뺏기고 말았습니다."); $logger->pushGlobalHistoryLog("<M><b>【패퇴】</b></><D><b>{$defender->getNationVar('name')}</b></>{$josaYi} 병량 부족으로 <G><b>{$defender->getName()}</b></>{$josaUl} 뺏기고 말았습니다.");
$conquerCity = true; $conquerCity = true;
$emitTrace('supply_retreat', $defender);
break; break;
} }
} }
@@ -332,18 +449,34 @@ function processWar_NG(
$initCaller->merge($defender->getGeneral()->getBattleInitSkillTriggerList($defender)); $initCaller->merge($defender->getGeneral()->getBattleInitSkillTriggerList($defender));
$initCaller->fire($attacker->rng, [], [$attacker, $defender]); $initCaller->fire($attacker->rng, [], [$attacker, $defender]);
$emitTrace('opponent_initialized', $defender);
} }
$attacker->beginPhase(); $attacker->beginPhase();
$defender->beginPhase(); $defender->beginPhase();
if ($trace !== null) {
$emitTrace('phase_power', $defender, [
'attackerAttack' => $attacker->getComputedAttack(),
'attackerDefence' => $attacker->getComputedDefence(),
'attackerTrain' => $attacker->getComputedTrain(),
'attackerAtmos' => $attacker->getComputedAtmos(),
'defenderAttack' => $defender->getComputedAttack(),
'defenderDefence' => $defender->getComputedDefence(),
'defenderTrain' => $defender->getComputedTrain(),
'defenderAtmos' => $defender->getComputedAtmos(),
]);
}
$battleCaller = $attacker->getGeneral()->getBattlePhaseSkillTriggerList($attacker); $battleCaller = $attacker->getGeneral()->getBattlePhaseSkillTriggerList($attacker);
$battleCaller->merge($defender->getGeneral()->getBattlePhaseSkillTriggerList($defender)); $battleCaller->merge($defender->getGeneral()->getBattlePhaseSkillTriggerList($defender));
$battleCaller->fire($attacker->rng, [], [$attacker, $defender]); $battleCaller->fire($attacker->rng, [], [$attacker, $defender]);
$emitTrace('phase_triggered', $defender);
$deadDefender = $attacker->calcDamage(); $deadDefender = $attacker->calcDamage();
$deadAttacker = $defender->calcDamage(); $deadAttacker = $defender->calcDamage();
$rawDeadAttacker = $deadAttacker;
$rawDeadDefender = $deadDefender;
$attackerHP = $attacker->getHP(); $attackerHP = $attacker->getHP();
$defenderHP = $defender->getHP(); $defenderHP = $defender->getHP();
@@ -371,6 +504,14 @@ function processWar_NG(
$attacker->increaseKilled($deadDefender); $attacker->increaseKilled($deadDefender);
$defender->increaseKilled($deadAttacker); $defender->increaseKilled($deadAttacker);
$emitTrace('phase_damage', $defender, [
'rawDeadAttacker' => $rawDeadAttacker,
'rawDeadDefender' => $rawDeadDefender,
'deadAttacker' => $deadAttacker,
'deadDefender' => $deadDefender,
'attackerHpBefore' => $attackerHP,
'defenderHpBefore' => $defenderHP,
]);
if($defender->getPhase() < 0){ if($defender->getPhase() < 0){
$phaseNickname = '先'; $phaseNickname = '先';
@@ -394,8 +535,10 @@ function processWar_NG(
$attacker->addPhase(); $attacker->addPhase();
$defender->addPhase(); $defender->addPhase();
$emitTrace('phase_end', $defender);
if (!$attacker->continueWar($noRice)) { if (!$attacker->continueWar($noRice)) {
$emitTrace('attacker_stopped', $defender, ['noRice' => (bool)$noRice]);
$logWritten = true; $logWritten = true;
$attacker->logBattleResult(); $attacker->logBattleResult();
@@ -420,6 +563,7 @@ function processWar_NG(
} }
if (!$defender->continueWar($noRice)) { if (!$defender->continueWar($noRice)) {
$emitTrace('defender_stopped', $defender, ['noRice' => (bool)$noRice]);
$logWritten = true; $logWritten = true;
$attacker->logBattleResult(); $attacker->logBattleResult();
@@ -462,6 +606,7 @@ function processWar_NG(
$defender->finishBattle(); $defender->finishBattle();
$defender = ($getNextDefender)($defender, true); $defender = ($getNextDefender)($defender, true);
$emitTrace('opponent_switched', $defender);
if ($defender !== null && !($defender instanceof WarUnitGeneral)) { if ($defender !== null && !($defender instanceof WarUnitGeneral)) {
throw new \RuntimeException('다음 수비자를 받아오는데 실패'); throw new \RuntimeException('다음 수비자를 받아오는데 실패');
@@ -497,10 +642,62 @@ function processWar_NG(
} }
($getNextDefender)($defender, false); ($getNextDefender)($defender, false);
$emitTrace('battle_end', $defender, ['conquered' => $conquerCity]);
return $conquerCity; return $conquerCity;
} }
function buildWarTraceUnitSnapshot(WarUnit $unit): array
{
$snapshot = [
'kind' => $unit instanceof WarUnitGeneral ? 'general' : 'city',
'id' => $unit instanceof WarUnitGeneral
? $unit->getGeneral()->getID()
: $unit->getVar('city'),
'name' => $unit->getName(),
'isAttacker' => $unit->isAttacker(),
'crewTypeId' => $unit->getCrewType()->id,
'phase' => $unit->getPhase(),
'realPhase' => $unit->getRealPhase(),
'maxPhase' => $unit->getMaxPhase(),
'hp' => $unit->getHP(),
// Ref leaves warPower uninitialized until beginPhase(). Canonical
// traces use numeric zero for that not-yet-computed state, as Core does.
'rawWarPower' => $unit->getRawWarPower() ?? 0,
'warPower' => $unit->getWarPower(),
'warPowerMultiplier' => $unit->getWarPowerMultiply(),
'killed' => $unit->getKilled(),
'dead' => $unit->getDead(),
'activatedSkills' => $unit->getActivatedSkillTraceSnapshot(),
];
if ($unit instanceof WarUnitGeneral) {
$general = $unit->getGeneral();
$snapshot['general'] = [
'crew' => $general->getVar('crew'),
'rice' => $general->getVar('rice'),
'train' => $general->getVar('train'),
'atmos' => $general->getVar('atmos'),
'injury' => $general->getVar('injury'),
'experience' => $general->getVar('experience'),
'dedication' => $general->getVar('dedication'),
'dex1' => $general->getVar('dex1'),
'dex2' => $general->getVar('dex2'),
'dex3' => $general->getVar('dex3'),
'dex4' => $general->getVar('dex4'),
'dex5' => $general->getVar('dex5'),
];
} else {
$snapshot['cityState'] = [
'defence' => $unit->getVar('def'),
'wall' => $unit->getVar('wall'),
'population' => $unit->getVar('pop'),
];
}
return $snapshot;
}
function DeleteConflict($nation) function DeleteConflict($nation)
{ {
$db = DB::db(); $db = DB::db();
+13 -1
View File
@@ -8,6 +8,7 @@ use sammo\AppConf;
use sammo\Enums\APIRecoveryType; use sammo\Enums\APIRecoveryType;
use sammo\KVStorage; use sammo\KVStorage;
use sammo\RootDB; use sammo\RootDB;
use sammo\RemoteUserIconUploadClient;
use sammo\TimeUtil; use sammo\TimeUtil;
use sammo\UniqueConst; use sammo\UniqueConst;
use sammo\Validator; use sammo\Validator;
@@ -60,6 +61,16 @@ class UploadImage extends \sammo\BaseAPI
$imgName = hash_final($oMD); $imgName = hash_final($oMD);
$imgFullName = "{$imgName}.{$extension}"; $imgFullName = "{$imgName}.{$extension}";
$remotePath = null;
if (RemoteUserIconUploadClient::isConfiguredEnabled()) {
try {
RemoteUserIconUploadClient::uploadContentConfigured($imgFullName, $contentType, $imageData);
$remotePath = RemoteUserIconUploadClient::getConfiguredContentPublicUrl($imgFullName);
} catch (\Throwable $error) {
RemoteUserIconUploadClient::logFailure('content-image', $error);
return '원격 이미지 저장소 업로드에 실패했습니다!';
}
} else {
$destDir = AppConf::getUserIconPathFS() . '/uploaded_image'; $destDir = AppConf::getUserIconPathFS() . '/uploaded_image';
$destPath = "{$destDir}/{$imgFullName}"; $destPath = "{$destDir}/{$imgFullName}";
@@ -78,6 +89,7 @@ class UploadImage extends \sammo\BaseAPI
return '업로드에 실패했습니다!'; return '업로드에 실패했습니다!';
} }
} }
}
$db = RootDB::db(); $db = RootDB::db();
$imgStor = KVStorage::getStorage($db, 'img_storage'); $imgStor = KVStorage::getStorage($db, 'img_storage');
@@ -96,7 +108,7 @@ class UploadImage extends \sammo\BaseAPI
return [ return [
'result' => true, 'result' => true,
'path'=>AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName, 'path'=>$remotePath ?? AppConf::getUserIconPathWeb().'/uploaded_image/'.$imgFullName,
]; ];
} }
} }
+8 -4
View File
@@ -151,6 +151,11 @@ class GeneralList extends \sammo\BaseAPI
$gameStor = \sammo\KVStorage::getStorage($db, 'game_env'); $gameStor = \sammo\KVStorage::getStorage($db, 'game_env');
$env = $gameStor->getValues(['year', 'month', 'turntime', 'turnterm', 'autorun_user', 'killturn']); $env = $gameStor->getValues(['year', 'month', 'turntime', 'turnterm', 'autorun_user', 'killturn']);
$clock = GameClock::isInitialized($gameStor) ? GameClock::fromStorage($gameStor) : null;
$formatStoredTime = static fn (mixed $value): string => $clock === null
? (string)$value
: $clock->formatTick(Util::toInt($value));
$env['turntime'] = $formatStoredTime($env['turntime']);
$me = $db->queryFirstRow( $me = $db->queryFirstRow(
'SELECT refresh_score, turntime, belong, nation, officer_level, permission, penalty FROM `general` 'SELECT refresh_score, turntime, belong, nation, officer_level, permission, penalty FROM `general`
@@ -187,7 +192,7 @@ class GeneralList extends \sammo\BaseAPI
if (!key_exists($troopLeaderID, $rawGeneralList)) { if (!key_exists($troopLeaderID, $rawGeneralList)) {
continue; continue;
} }
$troopTurnTime = $rawGeneralList[$troopLeaderID]['turntime']; $troopTurnTime = $formatStoredTime($rawGeneralList[$troopLeaderID]['turntime']);
$troops[$troopLeaderID] = new ArrayObject([ $troops[$troopLeaderID] = new ArrayObject([
'id' => $troopLeaderID, 'id' => $troopLeaderID,
'name' => $troopName, 'name' => $troopName,
@@ -265,11 +270,10 @@ class GeneralList extends \sammo\BaseAPI
'honorText' => fn ($rawGeneral) => getHonor($rawGeneral['experience']), 'honorText' => fn ($rawGeneral) => getHonor($rawGeneral['experience']),
'dedLevelText' => fn ($rawGeneral) => getDedLevelText($rawGeneral['dedlevel']), 'dedLevelText' => fn ($rawGeneral) => getDedLevelText($rawGeneral['dedlevel']),
//'0000-00-00 11:23'; //'0000-00-00 11:23';
'turntime' => fn ($rawGeneral) => GameClock::fromStorage($gameStor) 'turntime' => fn ($rawGeneral) => substr($formatStoredTime($rawGeneral['turntime']), 0, 19),
->formatTick(Util::toInt($rawGeneral['turntime'])),
'recent_war' => fn ($rawGeneral) => $rawGeneral['recent_war'] === null 'recent_war' => fn ($rawGeneral) => $rawGeneral['recent_war'] === null
? null ? null
: GameClock::fromStorage($gameStor)->formatTick(Util::toInt($rawGeneral['recent_war'])), : substr($formatStoredTime($rawGeneral['recent_war']), 0, 19),
'bill' => fn ($rawGeneral) => getBillByLevel($rawGeneral['dedlevel']), 'bill' => fn ($rawGeneral) => getBillByLevel($rawGeneral['dedlevel']),
'reservedCommand' => fn ($rawGeneral) => $reservedCommand[$rawGeneral['no']] ?? null, 'reservedCommand' => fn ($rawGeneral) => $reservedCommand[$rawGeneral['no']] ?? null,
'autorun_limit' => fn ($rawGeneral) => ($rawGeneral['aux'] ?? [])['autorun_limit'] ?? 0, 'autorun_limit' => fn ($rawGeneral) => ($rawGeneral['aux'] ?? [])['autorun_limit'] ?? 0,
+3 -1
View File
@@ -64,7 +64,9 @@ class che_견문 extends Command\GeneralCommand{
$sightseeing = new SightseeingMessage(); $sightseeing = new SightseeingMessage();
[$type, $text] = $sightseeing->pickAction(); // The command RNG is derived from the global seed, year/month,
// general id, and action. Avoid PHP's process-global mt_rand here.
[$type, $text] = $sightseeing->pickAction($rng);
$exp = 0; $exp = 0;
+24 -1
View File
@@ -2,7 +2,7 @@
namespace sammo\Command\General; namespace sammo\Command\General;
use \sammo\{ use \sammo\{
DB, Util, JosaUtil, DB, Util, JosaUtil, Json,
General, General,
ActionLogger, ActionLogger,
LastTurn, LastTurn,
@@ -123,6 +123,29 @@ class che_기술연구 extends che_상업투자{
GameConst::$initialNationGenLimit GameConst::$initialNationGenLimit
); );
$traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS');
$traceNationIds = getenv('REF_AI_TRACE_NATION_IDS');
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& (
(is_string($traceGeneralIds)
&& in_array((string) $general->getID(), explode(',', $traceGeneralIds), true))
|| (is_string($traceNationIds)
&& in_array((string) $general->getVar('nation'), explode(',', $traceNationIds), true))
)
) {
fwrite(STDOUT, 'AI_ACTION_PATCH_TRACE ' . Json::encode([
'engine' => 'ref-tech',
'generalId' => $general->getID(),
'nationId' => $general->getVar('nation'),
'currentTech' => $this->nation['tech'],
'techScore' => $score,
'generalCount' => $genCount,
'delta' => $score / $genCount,
]) . "\n");
}
$nationUpdated = [ $nationUpdated = [
'tech' => $this->nation['tech'] + $score/$genCount 'tech' => $this->nation['tech'] + $score/$genCount
]; ];
@@ -187,7 +187,8 @@ class che_랜덤임관 extends Command\GeneralCommand
LEFT JOIN `rank_data` AS rb ON g.`no` = rb.general_id AND rb.`type` = 'deathcrew_person' LEFT JOIN `rank_data` AS rb ON g.`no` = rb.general_id AND rb.`type` = 'deathcrew_person'
LEFT JOIN `nation` AS n ON g.`nation` = n.`nation` LEFT JOIN `nation` AS n ON g.`nation` = n.`nation`
WHERE g.`npc` IN (0, 1, 2, 3, 6) AND g.nation != 0 AND n.scout=0 AND n.gennum < %i AND n.nation NOT IN %li WHERE g.`npc` IN (0, 1, 2, 3, 6) AND g.nation != 0 AND n.scout=0 AND n.gennum < %i AND n.nation NOT IN %li
GROUP BY g.`nation`", GROUP BY g.`nation`
ORDER BY g.`nation`",
$genLimit, $genLimit,
$notIn $notIn
); );
@@ -199,7 +200,8 @@ class che_랜덤임관 extends Command\GeneralCommand
LEFT JOIN `rank_data` AS rb ON g.`no` = rb.general_id AND rb.`type` = 'deathcrew_person' LEFT JOIN `rank_data` AS rb ON g.`no` = rb.general_id AND rb.`type` = 'deathcrew_person'
LEFT JOIN `nation` AS n ON g.`nation` = n.`nation` LEFT JOIN `nation` AS n ON g.`nation` = n.`nation`
WHERE g.`npc` IN (0, 1, 2, 3, 6) AND g.nation != 0 AND n.scout=0 AND n.gennum < %i WHERE g.`npc` IN (0, 1, 2, 3, 6) AND g.nation != 0 AND n.scout=0 AND n.gennum < %i
GROUP BY g.`nation`", GROUP BY g.`nation`
ORDER BY g.`nation`",
$genLimit $genLimit
); );
} }
+37 -4
View File
@@ -31,6 +31,7 @@ class che_상업투자 extends Command\GeneralCommand{
static protected $debuffFront = 0.5; static protected $debuffFront = 0.5;
protected $reqGold; protected $reqGold;
protected $baseTrace = [];
protected function argTest():bool{ protected function argTest():bool{
$this->arg = null; $this->arg = null;
@@ -118,10 +119,21 @@ class che_상업투자 extends Command\GeneralCommand{
throw new \sammo\MustNotBeReachedException(); throw new \sammo\MustNotBeReachedException();
} }
$adjustedStat = $score;
$score *= $trust / 100; $score *= $trust / 100;
$score *= getDomesticExpLevelBonus($general->getVar('explevel')); $expLevel = $general->getVar('explevel');
$score *= $rng->nextRange(0.8, 1.2); $expBonus = getDomesticExpLevelBonus($expLevel);
$score *= $expBonus;
$randomMultiplier = $rng->nextRange(0.8, 1.2);
$score *= $randomMultiplier;
$score = $general->onCalcDomestic(static::$actionKey, 'score', $score); $score = $general->onCalcDomestic(static::$actionKey, 'score', $score);
$this->baseTrace = [
'adjustedStat' => $adjustedStat,
'expLevel' => $expLevel,
'expBonus' => $expBonus,
'randomMultiplier' => $randomMultiplier,
'domesticScore' => $score,
];
return $score; return $score;
} }
@@ -137,7 +149,8 @@ class che_상업투자 extends Command\GeneralCommand{
$trust = Util::valueFit($this->city['trust'], 50); $trust = Util::valueFit($this->city['trust'], 50);
$score = Util::valueFit($this->calcBaseScore($rng), 1); $calculatedBaseScore = $this->calcBaseScore($rng);
$score = Util::valueFit($calculatedBaseScore, 1);
['success'=>$successRatio, 'fail'=>$failRatio] = CriticalRatioDomestic($general, static::$statKey); ['success'=>$successRatio, 'fail'=>$failRatio] = CriticalRatioDomestic($general, static::$statKey);
if($trust < 80){ if($trust < 80){
@@ -160,12 +173,32 @@ class che_상업투자 extends Command\GeneralCommand{
$date = $general->getTurnTime($general::TURNTIME_HM); $date = $general->getTurnTime($general::TURNTIME_HM);
$score *= CriticalScoreEx($rng, $pick); $criticalMultiplier = CriticalScoreEx($rng, $pick);
$score *= $criticalMultiplier;
$score = Util::round($score); $score = Util::round($score);
$exp = $score * 0.7; $exp = $score * 0.7;
$ded = $score * 1.0; $ded = $score * 1.0;
if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) {
fwrite(STDOUT, 'DOMESTIC_CALC_TRACE ' . json_encode([
'engine' => 'ref',
'generalId' => $general->getID(),
'action' => static::class,
'trust' => $trust,
'calculatedBaseScore' => $calculatedBaseScore,
'baseTrace' => $this->baseTrace,
'successRatio' => $successRatio,
'failRatio' => $failRatio,
'normalRatio' => $normalRatio,
'pick' => $pick,
'criticalMultiplier' => $criticalMultiplier,
'rewardScore' => $score,
'exp' => $exp,
'dedication' => $ded,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
}
if($pick == 'success'){ if($pick == 'success'){
updateMaxDomesticCritical($general, $score); updateMaxDomesticCritical($general, $score);
} }
+37 -4
View File
@@ -30,6 +30,7 @@ class che_주민선정 extends Command\GeneralCommand{
static protected $actionName = '주민 선정'; static protected $actionName = '주민 선정';
protected $reqRice; protected $reqRice;
protected $baseTrace = [];
protected function argTest():bool{ protected function argTest():bool{
$this->arg = null; $this->arg = null;
@@ -109,10 +110,21 @@ class che_주민선정 extends Command\GeneralCommand{
throw new \sammo\MustNotBeReachedException(); throw new \sammo\MustNotBeReachedException();
} }
$score *= getDomesticExpLevelBonus($general->getVar('explevel')); $adjustedStat = $score;
$score *= $rng->nextRange(0.8, 1.2); $expLevel = $general->getVar('explevel');
$expBonus = getDomesticExpLevelBonus($expLevel);
$score *= $expBonus;
$randomMultiplier = $rng->nextRange(0.8, 1.2);
$score *= $randomMultiplier;
$score = $general->onCalcDomestic(static::$actionKey, 'score', $score); $score = $general->onCalcDomestic(static::$actionKey, 'score', $score);
$score = Util::valueFit($score, 1); $score = Util::valueFit($score, 1);
$this->baseTrace = [
'adjustedStat' => $adjustedStat,
'expLevel' => $expLevel,
'expBonus' => $expBonus,
'randomMultiplier' => $randomMultiplier,
'domesticScore' => $score,
];
return $score; return $score;
} }
@@ -126,7 +138,8 @@ class che_주민선정 extends Command\GeneralCommand{
$general = $this->generalObj; $general = $this->generalObj;
$score = Util::valueFit($this->calcBaseScore($rng), 1); $calculatedBaseScore = $this->calcBaseScore($rng);
$score = Util::valueFit($calculatedBaseScore, 1);
['success'=>$successRatio, 'fail'=>$failRatio] = CriticalRatioDomestic($general, static::$statKey); ['success'=>$successRatio, 'fail'=>$failRatio] = CriticalRatioDomestic($general, static::$statKey);
$successRatio = $general->onCalcDomestic(static::$actionKey, 'success', $successRatio); $successRatio = $general->onCalcDomestic(static::$actionKey, 'success', $successRatio);
@@ -146,11 +159,31 @@ class che_주민선정 extends Command\GeneralCommand{
$date = $general->getTurnTime($general::TURNTIME_HM); $date = $general->getTurnTime($general::TURNTIME_HM);
$score *= CriticalScoreEx($rng, $pick); $criticalMultiplier = CriticalScoreEx($rng, $pick);
$score *= $criticalMultiplier;
$exp = $score * 0.7; $exp = $score * 0.7;
$ded = $score * 1.0; $ded = $score * 1.0;
if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) {
fwrite(STDOUT, 'DOMESTIC_CALC_TRACE ' . json_encode([
'engine' => 'ref',
'generalId' => $general->getID(),
'action' => static::class,
'calculatedBaseScore' => $calculatedBaseScore,
'baseTrace' => $this->baseTrace,
'successRatio' => $successRatio,
'failRatio' => $failRatio,
'normalRatio' => $normalRatio,
'pick' => $pick,
'criticalMultiplier' => $criticalMultiplier,
'rewardScore' => $score,
'appliedScore' => $score / 10,
'exp' => $exp,
'dedication' => $ded,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
}
if($pick == 'success'){ if($pick == 'success'){
updateMaxDomesticCritical($general, $score); updateMaxDomesticCritical($general, $score);
} }
@@ -37,7 +37,7 @@ class AssignGeneralSpeciality extends \sammo\Event\Action
$month, $month,
))); )));
foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,aux from general where specage<=age and special=%s', GameConst::$defaultSpecialDomestic) as $general) { foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,aux from general where specage<=age and special=%s ORDER BY no ASC', GameConst::$defaultSpecialDomestic) as $general) {
$generalID = $general['no']; $generalID = $general['no'];
$special = SpecialityHelper::pickSpecialDomestic( $special = SpecialityHelper::pickSpecialDomestic(
$rng, $rng,
@@ -57,7 +57,7 @@ class AssignGeneralSpeciality extends \sammo\Event\Action
$logger->pushGeneralHistoryLog("특기 【<b><C>{$specialText}</></b>】{$josaUl} 습득"); $logger->pushGeneralHistoryLog("특기 【<b><C>{$specialText}</></b>】{$josaUl} 습득");
} }
foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,npc,dex1,dex2,dex3,dex4,dex5,aux from general where specage2<=age and special2=%s', GameConst::$defaultSpecialWar) as $general) { foreach ($db->query('SELECT no,name,nation,leadership,strength,intel,npc,dex1,dex2,dex3,dex4,dex5,aux from general where specage2<=age and special2=%s ORDER BY no ASC', GameConst::$defaultSpecialWar) as $general) {
$generalID = $general['no']; $generalID = $general['no'];
$generalAux = Json::decode($general['aux']); $generalAux = Json::decode($general['aux']);
+2
View File
@@ -2,6 +2,7 @@
namespace sammo\Event\Action; namespace sammo\Event\Action;
use sammo\ActionLogger; use sammo\ActionLogger;
use sammo\AutoResetProgress;
use sammo\CityConst; use sammo\CityConst;
use sammo\DB; use sammo\DB;
use sammo\KVStorage; use sammo\KVStorage;
@@ -61,6 +62,7 @@ class InvaderEnding extends \sammo\Event\Action{
$logger->pushGlobalHistoryLog("<L><b>【이벤트】</b></>백성은 언젠가 영웅이 나타나길 기다립니다."); $logger->pushGlobalHistoryLog("<L><b>【이벤트】</b></>백성은 언젠가 영웅이 나타나길 기다립니다.");
} }
$gameStor->setValue('isunited', 3); $gameStor->setValue('isunited', 3);
AutoResetProgress::record($gameStor);
$logger->flush(); $logger->flush();
$gameStor->refreshLimit = $gameStor->refreshLimit * 100; $gameStor->refreshLimit = $gameStor->refreshLimit * 100;
+15
View File
@@ -94,6 +94,21 @@ class ProcessIncome extends \sammo\Event\Action
foreach ($generalRawList as $rawGeneral) { foreach ($generalRawList as $rawGeneral) {
$generalObj = new General($rawGeneral, null, null, null, null, $year, $month, false); $generalObj = new General($rawGeneral, null, null, null, null, $year, $month, false);
$gold = Util::round(getBill($generalObj->getVar('dedication')) * $ratio); $gold = Util::round(getBill($generalObj->getVar('dedication')) * $ratio);
if (
getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& getenv('SEED_PARITY_MONTHLY_RESOURCE_TRACE') === '1'
&& in_array((string)$generalObj->getID(), explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), true)
) {
fwrite(STDOUT, 'MONTHLY_RESOURCE_REF ' . json_encode([
'action' => 'ProcessIncome',
'type' => 'gold',
'generalId' => $generalObj->getID(),
'current' => $generalObj->getVar('gold'),
'pay' => $gold,
'ratio' => $ratio,
'originOutcome' => $originoutcome,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
}
$generalObj->increaseVar('gold', $gold); $generalObj->increaseVar('gold', $gold);
$logger = $generalObj->getLogger(); $logger = $generalObj->getLogger();
@@ -71,6 +71,21 @@ class ProcessSemiAnnual extends \sammo\Event\Action
$resource = $this->resource; $resource = $this->resource;
$traceGeneralIDs = array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen');
if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' && getenv('SEED_PARITY_MONTHLY_RESOURCE_TRACE') === '1') {
foreach ($traceGeneralIDs as $generalID) {
$current = $db->queryFirstField('SELECT %b FROM general WHERE no = %i', $resource, (int)$generalID);
if ($current !== null) {
fwrite(STDOUT, 'MONTHLY_RESOURCE_REF ' . json_encode([
'action' => 'ProcessSemiAnnual',
'resource' => $resource,
'generalId' => (int)$generalID,
'current' => (int)$current,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
}
}
}
// 내정 1% 감소 // 내정 1% 감소
$db->update('city', [ $db->update('city', [
'dead' => 0, 'dead' => 0,
@@ -90,6 +105,20 @@ class ProcessSemiAnnual extends \sammo\Event\Action
$resource => $db->sqleval('IF(%b > 10000, %b * 0.97, %b * 0.99)', $resource, $resource, $resource) $resource => $db->sqleval('IF(%b > 10000, %b * 0.97, %b * 0.99)', $resource, $resource, $resource)
], '%b > 1000', $resource); ], '%b > 1000', $resource);
if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1' && getenv('SEED_PARITY_MONTHLY_RESOURCE_TRACE') === '1') {
foreach ($traceGeneralIDs as $generalID) {
$next = $db->queryFirstField('SELECT %b FROM general WHERE no = %i', $resource, (int)$generalID);
if ($next !== null) {
fwrite(STDOUT, 'MONTHLY_RESOURCE_REF ' . json_encode([
'action' => 'ProcessSemiAnnualAfter',
'resource' => $resource,
'generalId' => (int)$generalID,
'next' => (int)$next,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
}
}
}
// > 100000 유지비 5%, > 100000 유지비 3%, > 1000 유지비 1% // > 100000 유지비 5%, > 100000 유지비 3%, > 1000 유지비 1%
$db->update('nation', [ $db->update('nation', [
$resource => $db->sqleval('IF(%b > 100000, %b * 0.95, IF(%b > 10000, %b * 0.97, %b * 0.99))', $resource, $resource, $resource, $resource, $resource) $resource => $db->sqleval('IF(%b > 100000, %b * 0.95, IF(%b > 10000, %b * 0.97, %b * 0.99))', $resource, $resource, $resource, $resource, $resource)
+27 -1
View File
@@ -114,7 +114,7 @@ class RaiseDisaster extends \sammo\Event\Action
if (!$isGood) { if (!$isGood) {
//FIXME: factory 형태로 바꿔야함 //FIXME: factory 형태로 바꿔야함
$generalListByCity = Util::arrayGroupBy($db->query( $generalListByCity = Util::arrayGroupBy($db->query(
'SELECT %l FROM general WHERE city IN %li', 'SELECT %l FROM general WHERE city IN %li ORDER BY city, no',
Util::formatListOfBackticks(General::mergeQueryColumn()[0]), Util::formatListOfBackticks(General::mergeQueryColumn()[0]),
Util::squeezeFromArray($targetCityList, 'city')), Util::squeezeFromArray($targetCityList, 'city')),
'city'); 'city');
@@ -133,6 +133,19 @@ class RaiseDisaster extends \sammo\Event\Action
'def' => $db->sqleval('def * %d', $affectRatio), 'def' => $db->sqleval('def * %d', $affectRatio),
'wall' => $db->sqleval('wall * %d', $affectRatio), 'wall' => $db->sqleval('wall * %d', $affectRatio),
], 'city = %i', $city['city']); ], 'city = %i', $city['city']);
if (in_array((string)$city['city'], array_filter(explode(',', (string)getenv('REF_AI_TRACE_CITY_IDS')), 'strlen'), true)) {
$storedTrust = $db->queryFirstField('SELECT trust FROM city WHERE city = %i', $city['city']);
fwrite(STDOUT, 'MONTHLY_FLOAT_TRACE ' . json_encode([
'engine' => 'ref',
'cityId' => $city['city'],
'year' => $year,
'month' => $month,
'isGood' => false,
'inputTrust' => $city['trust'],
'affectRatio' => $affectRatio,
'storedTrust' => $storedTrust,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
}
$generalList = array_map( $generalList = array_map(
function ($rawGeneral) use ($city, $year, $month) { function ($rawGeneral) use ($city, $year, $month) {
@@ -158,6 +171,19 @@ class RaiseDisaster extends \sammo\Event\Action
'def' => $db->sqleval('least(def * %d, def_max)', $affectRatio), 'def' => $db->sqleval('least(def * %d, def_max)', $affectRatio),
'wall' => $db->sqleval('least(wall * %d, wall_max)', $affectRatio), 'wall' => $db->sqleval('least(wall * %d, wall_max)', $affectRatio),
], 'city = %i', $city['city']); ], 'city = %i', $city['city']);
if (in_array((string)$city['city'], array_filter(explode(',', (string)getenv('REF_AI_TRACE_CITY_IDS')), 'strlen'), true)) {
$storedTrust = $db->queryFirstField('SELECT trust FROM city WHERE city = %i', $city['city']);
fwrite(STDOUT, 'MONTHLY_FLOAT_TRACE ' . json_encode([
'engine' => 'ref',
'cityId' => $city['city'],
'year' => $year,
'month' => $month,
'isGood' => true,
'inputTrust' => $city['trust'],
'affectRatio' => $affectRatio,
'storedTrust' => $storedTrust,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
}
} }
} }
} }
+14
View File
@@ -4,6 +4,7 @@ namespace sammo\Event\Action;
use Ds\Set; use Ds\Set;
use sammo\ActionLogger; use sammo\ActionLogger;
use sammo\AutoResetProgress;
use sammo\CityConst; use sammo\CityConst;
use sammo\DB; use sammo\DB;
use sammo\Json; use sammo\Json;
@@ -99,6 +100,17 @@ class RaiseInvader extends \sammo\Event\Action
} }
public function run(array $env) public function run(array $env)
{
$previousIgnoreUserAbort = ignore_user_abort(true);
try {
return $this->runToCompletion($env);
}
finally {
ignore_user_abort($previousIgnoreUserAbort !== 0);
}
}
private function runToCompletion(array $env)
{ {
$db = DB::db(); $db = DB::db();
@@ -119,6 +131,7 @@ class RaiseInvader extends \sammo\Event\Action
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$gameStor->setValue('isunited', 1); $gameStor->setValue('isunited', 1);
AutoResetProgress::record($gameStor);
$turnterm = $gameStor->turnterm; $turnterm = $gameStor->turnterm;
$generalCnt = $db->queryFirstField('SELECT count(*) FROM general'); $generalCnt = $db->queryFirstField('SELECT count(*) FROM general');
@@ -194,6 +207,7 @@ class RaiseInvader extends \sammo\Event\Action
], true); ], true);
$invaderNationIDList = []; $invaderNationIDList = [];
$env = GeneralBuilder::prepareEnvironment($env);
refreshNationStaticInfo(); refreshNationStaticInfo();
foreach ($cities as $cityObj) { foreach ($cities as $cityObj) {
if ($cityObj->level != 4) { if ($cityObj->level != 4) {
+314 -9
View File
@@ -11,6 +11,28 @@ use sammo\Scenario\NPC;
class GeneralAI class GeneralAI
{ {
private function traceNationRng(string $phase): void
{
$traceNationIds = getenv('REF_AI_TRACE_NATION_IDS');
if (
PHP_SAPI !== 'cli'
|| getenv('REF_DETERMINISTIC_INSTALL_ENABLED') !== '1'
|| !is_string($traceNationIds)
|| !in_array((string) $this->general->getID(), explode(',', $traceNationIds), true)
) {
return;
}
$rng = $this->rng->rng;
$reflection = new \ReflectionObject($rng);
$bufferIdx = $reflection->getProperty('bufferIdx')->getValue($rng);
fwrite(STDOUT, 'AI_NATION_RNG_TRACE ' . Json::encode([
'generalId' => $this->general->getID(),
'phase' => $phase,
'bufferIdx' => $bufferIdx,
]) . "\n");
}
protected RandUtil $rng; protected RandUtil $rng;
protected array $city; protected array $city;
@@ -150,13 +172,25 @@ class GeneralAI
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$this->env = $gameStor->getAll(true); $this->env = $gameStor->getAll(true);
$this->rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( $serializedSeed = Util::simpleSerialize(
UniqueConst::$hiddenSeed, UniqueConst::$hiddenSeed,
'GeneralAI', 'GeneralAI',
$this->env['year'], $this->env['year'],
$this->env['month'], $this->env['month'],
$general->getID(), $general->getID(),
))); );
if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) {
fwrite(STDOUT, 'AI_GENERAL_SEED_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'year' => $this->env['year'],
'month' => $this->env['month'],
'seedHex' => bin2hex($serializedSeed),
]) . "\n");
}
$this->rng = new RandUtil(new LiteHashDRBG($serializedSeed));
if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) {
$this->rng->setTraceGeneralId($general->getID());
}
$this->leadership = $general->getLeadership(); $this->leadership = $general->getLeadership();
$this->strength = $general->getStrength(); $this->strength = $general->getStrength();
@@ -177,12 +211,16 @@ class GeneralAI
$leadership = $general->getLeadership(false); $leadership = $general->getLeadership(false);
$strength = Util::valueFit($general->getStrength(false), 1); $strength = Util::valueFit($general->getStrength(false), 1);
$intel = Util::valueFit($general->getIntel(false), 1); $intel = Util::valueFit($general->getIntel(false), 1);
$mixedDraw = null;
$mixedProbability = null;
//무장 //무장
if ($strength >= $intel) { if ($strength >= $intel) {
$genType = self::t무장; $genType = self::t무장;
if ($intel >= $strength * 0.8) { //무지장 if ($intel >= $strength * 0.8) { //무지장
if ($this->rng->nextBool($intel / $strength / 2)) { $mixedProbability = $intel / $strength / 2;
$mixedDraw = $this->rng->nextBool($mixedProbability);
if ($mixedDraw) {
$genType |= self::t지장; $genType |= self::t지장;
} }
} }
@@ -190,7 +228,9 @@ class GeneralAI
} else { } else {
$genType = self::t지장; $genType = self::t지장;
if ($strength >= $intel * 0.8) { //지무장 if ($strength >= $intel * 0.8) { //지무장
if ($this->rng->nextBool($strength / $intel / 2)) { $mixedProbability = $strength / $intel / 2;
$mixedDraw = $this->rng->nextBool($mixedProbability);
if ($mixedDraw) {
$genType |= self::t무장; $genType |= self::t무장;
} }
} }
@@ -200,6 +240,19 @@ class GeneralAI
if ($leadership >= $this->nationPolicy->minNPCWarLeadership) { if ($leadership >= $this->nationPolicy->minNPCWarLeadership) {
$genType |= self::t통솔장; $genType |= self::t통솔장;
} }
if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) {
fwrite(STDOUT, 'AI_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'stage' => 'calc-gen-type',
'leadership' => $leadership,
'strength' => $strength,
'intel' => $intel,
'mixedProbability' => $mixedProbability,
'mixedDraw' => $mixedDraw,
'minLeadership' => $this->nationPolicy->minNPCWarLeadership,
'genType' => $genType,
]) . "\n");
}
return $genType; return $genType;
} }
@@ -862,6 +915,14 @@ class GeneralAI
$cityCandidates[$frontCity['city']] = $frontCity['important']; $cityCandidates[$frontCity['city']] = $frontCity['important'];
} }
if (in_array((string)$me->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) {
fwrite(STDOUT, 'AI_NPC_FRONT_ASSIGN_TRACE ' . Json::encode([
'generalId' => $me->getID(),
'frontCityIds' => array_keys($cityCandidates),
'candidateGeneralIds' => array_keys($generalCandidates),
]) . "\n");
}
$cmd = buildNationCommandClass('che_발령', $this->general, $this->env, $lastTurn, [ $cmd = buildNationCommandClass('che_발령', $this->general, $this->env, $lastTurn, [
'destGeneralID' => $this->rng->choice($generalCandidates)->getID(), 'destGeneralID' => $this->rng->choice($generalCandidates)->getID(),
'destCityID' => $this->rng->choiceUsingWeight($cityCandidates) 'destCityID' => $this->rng->choiceUsingWeight($cityCandidates)
@@ -1140,6 +1201,14 @@ class GeneralAI
$cityCandidates[$frontCity['city']] = $frontCity['important']; $cityCandidates[$frontCity['city']] = $frontCity['important'];
} }
if (in_array((string)$me->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) {
fwrite(STDOUT, 'AI_NPC_FRONT_ASSIGN_TRACE ' . Json::encode([
'generalId' => $me->getID(),
'frontCityIds' => array_keys($cityCandidates),
'candidateGeneralIds' => array_keys($generalCandidates),
]) . "\n");
}
$cmd = buildNationCommandClass('che_발령', $this->general, $this->env, $lastTurn, [ $cmd = buildNationCommandClass('che_발령', $this->general, $this->env, $lastTurn, [
'destGeneralID' => $this->rng->choice($generalCandidates)->getID(), 'destGeneralID' => $this->rng->choice($generalCandidates)->getID(),
'destCityID' => $this->rng->choiceUsingWeight($cityCandidates) 'destCityID' => $this->rng->choiceUsingWeight($cityCandidates)
@@ -1571,11 +1640,26 @@ class GeneralAI
continue; continue;
} }
$payAmount = Util::valueFit($payAmount, 100, $this->maxResourceActionAmount);
if (in_array((string)$this->general->getID(), explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), true)) {
fwrite(STDOUT, 'AI_REWARD_TRACE ' . Json::encode([
'engine' => 'ref',
'actor' => $this->general->getID(),
'target' => $targetNPCGeneral->getID(),
'resource' => $resName,
'nationResource' => $resVal,
'targetResource' => $targetNPCGeneral->getVar($resName),
'required' => $reqMoney,
'enough' => $enoughMoney,
'maxResourceActionAmount' => $this->maxResourceActionAmount,
'amount' => $payAmount,
]) . "\n");
}
$candidateArgs[] = [ $candidateArgs[] = [
[ [
'destGeneralID' => $targetNPCGeneral->getID(), 'destGeneralID' => $targetNPCGeneral->getID(),
'isGold' => $resName == 'gold', 'isGold' => $resName == 'gold',
'amount' => Util::valueFit($payAmount, 100, $this->maxResourceActionAmount) 'amount' => $payAmount
], ],
max(count($npcWarGenerals), count($npcCivilGenerals)) - $idx max(count($npcWarGenerals), count($npcCivilGenerals)) - $idx
]; ];
@@ -2211,7 +2295,28 @@ class GeneralAI
return null; return null;
} }
return $this->rng->choiceUsingWeightPair($cmdList); $picked = $this->rng->choiceUsingWeightPair($cmdList);
$traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS');
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& is_string($traceGeneralIds)
&& in_array((string) $general->getID(), explode(',', $traceGeneralIds), true)
) {
fwrite(STDOUT, 'AI_DEVEL_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'year' => $this->env['year'],
'month' => $this->env['month'],
'genType' => $genType,
'city' => $city,
'candidates' => array_map(static fn (array $entry): array => [
'action' => $entry[0]->getRawClassName(),
'weight' => $entry[1],
], $cmdList),
'picked' => $picked->getRawClassName(),
]) . "\n");
}
return $picked;
} }
protected function do긴급내정(): ?GeneralCommand protected function do긴급내정(): ?GeneralCommand
@@ -2357,6 +2462,29 @@ class GeneralAI
} }
$cmd = $this->rng->choiceUsingWeightPair($cmdList); $cmd = $this->rng->choiceUsingWeightPair($cmdList);
$traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS');
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& is_string($traceGeneralIds)
&& in_array((string) $general->getID(), explode(',', $traceGeneralIds), true)
) {
fwrite(STDOUT, 'AI_DEVEL_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'year' => $this->env['year'],
'month' => $this->env['month'],
'mode' => '전쟁내정',
'genType' => $genType,
'dipState' => $this->dipState,
'city' => $city,
'nationTech' => $nation['tech'],
'candidates' => array_map(static fn (array $entry): array => [
'action' => $entry[0]->getRawClassName(),
'weight' => $entry[1],
], $cmdList),
'picked' => $cmd->getRawClassName(),
]) . "\n");
}
return $cmd; return $cmd;
} }
@@ -2364,8 +2492,21 @@ class GeneralAI
protected function do금쌀구매(): ?GeneralCommand protected function do금쌀구매(): ?GeneralCommand
{ {
$general = $this->general; $general = $this->general;
$traceGeneralIDs = array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen');
$traceEnabled = in_array((string)$general->getID(), $traceGeneralIDs, true);
$trace = static function (string $stage, array $values = []) use ($traceEnabled, $general): void {
if (!$traceEnabled) {
return;
}
fwrite(STDOUT, 'AI_ECONOMY_TRACE ' . json_encode([
'generalId' => $general->getID(),
'stage' => $stage,
...$values,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n");
};
if ($this->city['trade'] === null && !$this->generalPolicy->can상인무시) { if ($this->city['trade'] === null && !$this->generalPolicy->can상인무시) {
$trace('no-trader');
return null; return null;
} }
@@ -2379,7 +2520,19 @@ class GeneralAI
$relGold = $absGold; $relGold = $absGold;
$relRice = $absRice * $deathRate; $relRice = $absRice * $deathRate;
$trace('resources', [
'absGold' => $absGold,
'absRice' => $absRice,
'relGold' => $relGold,
'relRice' => $relRice,
'deathRate' => $deathRate,
'baseDevelCost' => $this->baseDevelCost,
'canIgnoreTrader' => $this->generalPolicy->can상인무시,
'trade' => $this->city['trade'],
]);
if ($absGold + $absRice < $this->baseDevelCost * 2) { if ($absGold + $absRice < $this->baseDevelCost * 2) {
$trace('insufficient-base-resource');
return null; return null;
} }
@@ -2401,6 +2554,15 @@ class GeneralAI
$this->nation['tech'], $this->nation['tech'],
Util::toInt($this->fullLeadership * 100) Util::toInt($this->fullLeadership * 100)
); );
$trace('recruit-cost', [
'crewTypeId' => $crewType->id,
'crewCost' => $crewType->cost,
'crewRice' => $crewType->rice,
'tech' => $this->nation['tech'],
'crewAmount' => Util::toInt($this->fullLeadership * 100),
'goldCost' => $goldCost,
'riceCost' => $riceCost,
]);
if (($relGold + $relRice) * 1.5 <= $goldCost + $riceCost) { if (($relGold + $relRice) * 1.5 <= $goldCost + $riceCost) {
return null; return null;
@@ -2435,7 +2597,13 @@ class GeneralAI
'amount' => $amount 'amount' => $amount
] ]
); );
if ($cmd->hasFullConditionMet()) { $conditionMet = $cmd->hasFullConditionMet();
$trace('buy', [
'amount' => $amount,
'minimumResourceActionAmount' => $this->nationPolicy->minimumResourceActionAmount,
'conditionMet' => $conditionMet,
]);
if ($conditionMet) {
return $cmd; return $cmd;
} }
} }
@@ -2479,11 +2647,25 @@ class GeneralAI
protected function do징병(): ?GeneralCommand protected function do징병(): ?GeneralCommand
{ {
$traceGeneralIds = array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen');
$traceEnabled = in_array((string)$this->general->getID(), $traceGeneralIds, true);
$trace = function (string $stage, array $values = []) use ($traceEnabled): void {
if (!$traceEnabled) {
return;
}
fwrite(STDOUT, 'AI_RECRUIT_TRACE ' . Json::encode([
'generalId' => $this->general->getID(),
'stage' => $stage,
...$values,
]) . "\n");
};
if (in_array($this->dipState, [self::d평화, self::d선포])) { if (in_array($this->dipState, [self::d평화, self::d선포])) {
$trace('diplomacy', ['dipState' => $this->dipState]);
return null; return null;
} }
if (!($this->genType & self::t통솔장)) { if (!($this->genType & self::t통솔장)) {
$trace('general-type', ['genType' => $this->genType]);
return null; return null;
} }
@@ -2495,12 +2677,22 @@ class GeneralAI
$env = $this->env; $env = $this->env;
if ($general->getVar('crew') >= $this->nationPolicy->minWarCrew) { if ($general->getVar('crew') >= $this->nationPolicy->minWarCrew) {
$trace('existing-crew', ['crew' => $general->getVar('crew'), 'minWarCrew' => $this->nationPolicy->minWarCrew]);
return null; return null;
} }
$trace('population-policy', [
'population' => $city['pop'],
'populationMax' => $city['pop_max'],
'safeRatio' => $this->nationPolicy->safeRecruitCityPopulationRatio,
'minPopulation' => $this->nationPolicy->minNPCRecruitCityPopulation,
'canLimitRecruit' => $this->generalPolicy->can한계징병,
]);
if (!$this->generalPolicy->can한계징병) { if (!$this->generalPolicy->can한계징병) {
$remainPop = $city['pop'] - $this->nationPolicy->minNPCRecruitCityPopulation - $this->fullLeadership * 100; $remainPop = $city['pop'] - $this->nationPolicy->minNPCRecruitCityPopulation - $this->fullLeadership * 100;
if ($remainPop <= 0) { if ($remainPop <= 0) {
$trace('population-floor', ['remainPop' => $remainPop, 'fullLeadership' => $this->fullLeadership]);
return null; return null;
} }
@@ -2508,6 +2700,7 @@ class GeneralAI
if (($city['pop'] / $city['pop_max'] < $this->nationPolicy->safeRecruitCityPopulationRatio) && if (($city['pop'] / $city['pop_max'] < $this->nationPolicy->safeRecruitCityPopulationRatio) &&
($this->rng->nextBool($remainPop / $maxPop)) ($this->rng->nextBool($remainPop / $maxPop))
) { ) {
$trace('population-random', ['remainPop' => $remainPop, 'maxPop' => $maxPop, 'fullLeadership' => $this->fullLeadership]);
return null; return null;
} }
} }
@@ -2548,8 +2741,26 @@ class GeneralAI
$availableArmType[GameUnitConst::T_WIZARD] = $dex[GameUnitConst::T_WIZARD] * $this->fullIntel * 3; $availableArmType[GameUnitConst::T_WIZARD] = $dex[GameUnitConst::T_WIZARD] * $this->fullIntel * 3;
} }
if ($traceEnabled) {
$armTypeDraw = $this->rng->nextFloat1();
$cursor = $armTypeDraw * array_sum($availableArmType);
foreach ($availableArmType as $candidateArmType => $weight) {
if ($cursor <= $weight) {
$armType = $candidateArmType;
break;
}
$cursor -= max(0, $weight);
}
} else {
$armType = $this->rng->choiceUsingWeight($availableArmType); $armType = $this->rng->choiceUsingWeight($availableArmType);
} }
}
$trace('arm-type', [
'forcedArmType' => $general->getAuxVar('armType'),
'armType' => $armType,
'armTypeDraw' => $armTypeDraw ?? null,
'armTypeWeights' => $availableArmType ?? [],
]);
$cities = []; $cities = [];
@@ -2575,6 +2786,7 @@ class GeneralAI
if ($types) { if ($types) {
$type = $this->rng->choiceUsingWeight($types); $type = $this->rng->choiceUsingWeight($types);
$trace('crew-type', ['armType' => $armType, 'candidates' => $types, 'picked' => $type]);
} else { } else {
throw new MustNotBeReachedException('에러:' . print_r([$general->getName(), $general->getAuxVar('armType'), $armType, $cities, $regions, $relYear, $tech], true)); throw new MustNotBeReachedException('에러:' . print_r([$general->getName(), $general->getAuxVar('armType'), $armType, $cities, $regions, $relYear, $tech], true));
} }
@@ -2602,6 +2814,7 @@ class GeneralAI
$rice -= $this->fullLeadership * 4; $rice -= $this->fullLeadership * 4;
if ($gold <= 0 || $rice <= 0) { if ($gold <= 0 || $rice <= 0) {
$trace('reserve-floor', ['remainingGold' => $gold, 'remainingRice' => $rice, 'fullLeadership' => $this->fullLeadership]);
return null; return null;
} }
@@ -2638,12 +2851,15 @@ class GeneralAI
if (!$this->generalPolicy->can한계징병 && $rice * 1.1 <= $riceCost) { if (!$this->generalPolicy->can한계징병 && $rice * 1.1 <= $riceCost) {
//이 쌀도 없어? //이 쌀도 없어?
$trace('rice-cost', ['remainingGold' => $gold, 'remainingRice' => $rice, 'goldCost' => $cost, 'riceCost' => $riceCost, 'crewAmount' => $crew, 'crewTypeId' => $type]);
return null; return null;
} }
if (!$cmd->hasFullConditionMet()) { if (!$cmd->hasFullConditionMet()) {
$trace('constraint', ['remainingGold' => $gold, 'remainingRice' => $rice, 'goldCost' => $cost, 'riceCost' => $riceCost, 'crewAmount' => $crew, 'crewTypeId' => $type]);
return null; return null;
} }
$trace('selected', ['remainingGold' => $gold, 'remainingRice' => $rice, 'goldCost' => $cost, 'riceCost' => $riceCost, 'crewAmount' => $crew, 'crewTypeId' => $type]);
return $cmd; return $cmd;
} }
@@ -2702,11 +2918,28 @@ class GeneralAI
protected function do출병(): ?GeneralCommand protected function do출병(): ?GeneralCommand
{ {
$traceEnabled = in_array((string)$this->general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true);
$trace = static function (string $stage, array $values = []) use ($traceEnabled): void {
if ($traceEnabled) {
fwrite(STDOUT, 'AI_WAR_TRACE ' . Json::encode(['engine' => 'ref', 'stage' => $stage] + $values) . "\n");
}
};
$trace('start', [
'generalId' => $this->general->getID(), 'attackable' => $this->attackable,
'dipState' => $this->dipState, 'nationRice' => $this->nation['rice'],
'baseRice' => GameConst::$baserice, 'train' => $this->general->getVar('train'),
'atmos' => $this->general->getVar('atmos'), 'crew' => $this->general->getVar('crew'),
'fullLeadership' => $this->fullLeadership, 'minWarCrew' => $this->nationPolicy->minWarCrew,
'cityID' => $this->city['city'], 'front' => $this->city['front'],
'warTargetNation' => $this->warTargetNation,
]);
if (!$this->attackable) { if (!$this->attackable) {
$trace('not-attackable');
return null; return null;
} }
if ($this->dipState !== self::d전쟁) { if ($this->dipState !== self::d전쟁) {
$trace('not-at-war');
return null; return null;
} }
@@ -2715,6 +2948,7 @@ class GeneralAI
$nation = $this->nation; $nation = $this->nation;
if (($nation['rice'] < GameConst::$baserice) && $general->getNPCType() >= 2 && $this->rng->nextBool(0.7)) { if (($nation['rice'] < GameConst::$baserice) && $general->getNPCType() >= 2 && $this->rng->nextBool(0.7)) {
$trace('low-nation-rice');
return null; return null;
} }
@@ -2724,20 +2958,25 @@ class GeneralAI
$db = DB::db(); $db = DB::db();
if ($general->getVar('train') < min(100, $this->nationPolicy->properWarTrainAtmos)) { if ($general->getVar('train') < min(100, $this->nationPolicy->properWarTrainAtmos)) {
$trace('low-train');
return null; return null;
} }
if ($general->getVar('atmos') < min(100, $this->nationPolicy->properWarTrainAtmos)) { if ($general->getVar('atmos') < min(100, $this->nationPolicy->properWarTrainAtmos)) {
$trace('low-atmos');
return null; return null;
} }
if ($general->getVar('crew') < min(($this->fullLeadership - 2) * 100, $this->nationPolicy->minWarCrew)) { if ($general->getVar('crew') < min(($this->fullLeadership - 2) * 100, $this->nationPolicy->minWarCrew)) {
$trace('low-crew');
return null; return null;
} }
if ($city['front'] === 0) { if ($city['front'] === 0) {
$trace('front-zero');
return null; return null;
} }
if ($city['front'] === 1) { if ($city['front'] === 1) {
$trace('front-one');
return null; return null;
} }
@@ -2758,6 +2997,7 @@ class GeneralAI
$attackableNations, $attackableNations,
$nearCities $nearCities
); );
$trace('destinations', ['attackableNations' => $attackableNations, 'nearCities' => $nearCities, 'attackableCities' => $attackableCities]);
if (count($attackableCities) == 0) { if (count($attackableCities) == 0) {
throw new \RuntimeException('출병 불가' . $cityID . var_export($attackableNations, true) . var_export($nearCities, true)); throw new \RuntimeException('출병 불가' . $cityID . var_export($attackableNations, true) . var_export($nearCities, true));
@@ -2765,6 +3005,14 @@ class GeneralAI
$cmd = buildGeneralCommandClass('che_출병', $general, $this->env, ['destCityID' => $this->rng->choice($attackableCities)]); $cmd = buildGeneralCommandClass('che_출병', $general, $this->env, ['destCityID' => $this->rng->choice($attackableCities)]);
if (!$cmd->hasFullConditionMet()) { if (!$cmd->hasFullConditionMet()) {
if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) {
error_log('AI_GENERAL_CONSTRAINT_TRACE ' . json_encode([
'generalId' => $general->getID(),
'action' => 'che_출병',
'args' => $cmd->getArg(),
'reason' => $cmd->testFullConditionMet(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
return null; return null;
} }
@@ -2951,6 +3199,16 @@ class GeneralAI
return null; return null;
} }
if (in_array((string) $this->general->getID(), array_filter(explode(',', (string) getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) {
fwrite(STDOUT, 'AI_WARP_TRACE ' . Json::encode([
'generalId' => $this->general->getID(),
'kind' => 'rear',
'fullLeadership' => $this->fullLeadership,
'minRecruitPop' => $minRecruitPop,
'recruitable' => $recruitableCityList,
]) . "\n");
}
$cmd = buildGeneralCommandClass('che_NPC능동', $this->general, $this->env, [ $cmd = buildGeneralCommandClass('che_NPC능동', $this->general, $this->env, [
'optionText' => '순간이동', 'optionText' => '순간이동',
@@ -3480,7 +3738,13 @@ class GeneralAI
$supplyCities = []; $supplyCities = [];
$backupCities = []; $backupCities = [];
foreach ($db->query('SELECT * FROM city WHERE nation = %i', $nationID) as $nationCity) { $cityQuery = 'SELECT * FROM city WHERE nation = %i';
if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1') {
// ng_compare must not let MariaDB's physical row order redefine
// which candidate a deterministic RNG index points at.
$cityQuery .= ' ORDER BY city';
}
foreach ($db->query($cityQuery, $nationID) as $nationCity) {
$nationCity['generals'] = new \ArrayObject(); $nationCity['generals'] = new \ArrayObject();
$cityID = $nationCity['city']; $cityID = $nationCity['city'];
$dev = $dev =
@@ -3531,7 +3795,11 @@ class GeneralAI
$nationCities = &$this->nationCities; $nationCities = &$this->nationCities;
$db = DB::db(); $db = DB::db();
$generalIDList = $db->queryFirstColumn('SELECT no FROM general WHERE nation = %i AND no != %i', $nationID, $this->general->getID()); $generalQuery = 'SELECT no FROM general WHERE nation = %i AND no != %i';
if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1') {
$generalQuery .= ' ORDER BY no';
}
$generalIDList = $db->queryFirstColumn($generalQuery, $nationID, $this->general->getID());
$nationGenerals = General::createObjListFromDB($generalIDList); $nationGenerals = General::createObjListFromDB($generalIDList);
@@ -3613,6 +3881,7 @@ class GeneralAI
public function chooseNationTurn(NationCommand $reservedCommand): NationCommand public function chooseNationTurn(NationCommand $reservedCommand): NationCommand
{ {
$this->updateInstance(); $this->updateInstance();
$this->traceNationRng('after-update');
//TODO: NationTurn과 InstantNationTurn 구분 필요 //TODO: NationTurn과 InstantNationTurn 구분 필요
$lastTurn = $reservedCommand->getLastTurn(); $lastTurn = $reservedCommand->getLastTurn();
@@ -3668,7 +3937,9 @@ class GeneralAI
continue; continue;
} }
/** @var ?NationCommand */ /** @var ?NationCommand */
$this->traceNationRng("before-{$actionName}");
$result = $this->{'do' . $actionName}($lastTurn); $result = $this->{'do' . $actionName}($lastTurn);
$this->traceNationRng("after-{$actionName}");
if ($result !== null) { if ($result !== null) {
$result->reason = 'do' . $actionName; $result->reason = 'do' . $actionName;
return $result; return $result;
@@ -3829,10 +4100,26 @@ class GeneralAI
continue; continue;
} }
if (!($this->generalPolicy->{'can' . $actionName})) { if (!($this->generalPolicy->{'can' . $actionName})) {
if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) {
fwrite(STDOUT, 'AI_GENERAL_PRIORITY_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'actionName' => $actionName,
'allowed' => false,
'result' => null,
]) . "\n");
}
continue; continue;
} }
/** @var ?GeneralCommand */ /** @var ?GeneralCommand */
$result = $this->{'do' . $actionName}(); $result = $this->{'do' . $actionName}();
if (in_array((string)$general->getID(), array_filter(explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), 'strlen'), true)) {
fwrite(STDOUT, 'AI_GENERAL_PRIORITY_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'actionName' => $actionName,
'allowed' => true,
'result' => $result === null ? null : get_class($result),
]) . "\n");
}
if ($result !== null) { if ($result !== null) {
$result->reason = 'do' . $actionName; $result->reason = 'do' . $actionName;
return $result; return $result;
@@ -3946,6 +4233,15 @@ class GeneralAI
$randGeneral->setVar('officer_level', $chiefLevel); $randGeneral->setVar('officer_level', $chiefLevel);
$randGeneral->setVar('officer_city', 0); $randGeneral->setVar('officer_city', 0);
if (getenv('REF_AI_TRACE_SEQUENCE') === '1') {
fwrite(STDOUT, 'AI_PROMOTION_TRACE ' . Json::encode([
'engine' => 'ref',
'mode' => 'non-lord',
'actor' => $this->general->getID(),
'chiefLevel' => $chiefLevel,
'picked' => $randGeneral->getID(),
]) . "\n");
}
$randGeneral->applyDB($db); $randGeneral->applyDB($db);
$this->nation['chief_set'] |= doOfficerSet(0, $chiefLevel); $this->nation['chief_set'] |= doOfficerSet(0, $chiefLevel);
$setChiefLevel |= doOfficerSet(0, $chiefLevel); $setChiefLevel |= doOfficerSet(0, $chiefLevel);
@@ -4143,6 +4439,15 @@ class GeneralAI
} }
$nextChiefs[$chiefLevel] = $newChief; $nextChiefs[$chiefLevel] = $newChief;
if (getenv('REF_AI_TRACE_SEQUENCE') === '1') {
fwrite(STDOUT, 'AI_PROMOTION_TRACE ' . Json::encode([
'engine' => 'ref',
'mode' => 'lord',
'actor' => $this->general->getID(),
'chiefLevel' => $chiefLevel,
'picked' => $newChief->getID(),
]) . "\n");
}
$newChief->setVar('officer_level', $chiefLevel); $newChief->setVar('officer_level', $chiefLevel);
$newChief->setVar('officer_city', 0); $newChief->setVar('officer_city', 0);
$nation['chief_set'] |= doOfficerSet(0, $chiefLevel); $nation['chief_set'] |= doOfficerSet(0, $chiefLevel);
@@ -31,7 +31,7 @@ class che_도시치료 extends BaseGeneralTrigger
if ($general->getNationID() == 0) { if ($general->getNationID() == 0) {
/** @var array{int,string,string}[] $patients */ /** @var array{int,string,string}[] $patients */
$patients = $db->queryAllLists( $patients = $db->queryAllLists(
'SELECT no,name,nation FROM general WHERE city=%i AND nation=%i AND injury > 10 AND no != %i', 'SELECT no,name,nation FROM general WHERE city=%i AND nation=%i AND injury > 10 AND no != %i ORDER BY no',
$general->getCityID(), $general->getCityID(),
0, 0,
$general->getID() $general->getID()
@@ -40,7 +40,7 @@ class che_도시치료 extends BaseGeneralTrigger
else { else {
/** @var array{int,string,string}[] $patients */ /** @var array{int,string,string}[] $patients */
$patients = $db->queryAllLists( $patients = $db->queryAllLists(
'SELECT no,name,nation FROM general WHERE city=%i AND injury > 10 AND no != %i', 'SELECT no,name,nation FROM general WHERE city=%i AND injury > 10 AND no != %i ORDER BY no',
$general->getCityID(), $general->getCityID(),
$general->getID() $general->getID()
); );
+23 -13
View File
@@ -152,19 +152,7 @@ class InheritancePointManager
}; };
switch ($key) { switch ($key) {
case InheritanceKey::dex: case InheritanceKey::dex:
$extractFn = function () use ($general, $multiplier) { $extractFn = fn () => [$this->getDexInheritancePoint($general), null];
$dexLimit = Util::array_last(getDexLevelList())[0];
$totalDex = 0;
foreach (array_keys(GameUnitConst::allType()) as $armType) {
$subDex = $general->getVar("dex{$armType}");
if ($subDex > $dexLimit) {
$totalDex += ($subDex - $dexLimit) / 3;
$subDex = $dexLimit;
}
$totalDex += $subDex;
}
return [$totalDex * $multiplier, null];
};
break; break;
case InheritanceKey::betting: case InheritanceKey::betting:
$extractFn = function () use ($general, $multiplier) { $extractFn = function () use ($general, $multiplier) {
@@ -189,6 +177,28 @@ class InheritancePointManager
return $value; return $value;
} }
/** @param null|int[] $armTypes */
public function getDexInheritancePoint(
General $general,
?int $dexLimit = null,
?array $armTypes = null
): float
{
$dexLimit ??= Util::array_last(getDexLevelList())[0];
$armTypes ??= array_keys(GameUnitConst::allType());
$multiplier = $this->getInheritancePointType(InheritanceKey::dex)->pointCoeff;
$totalDex = 0;
foreach ($armTypes as $armType) {
$subDex = CentennialAllStarGrowthService::recordableValue($general, "dex{$armType}");
if ($subDex > $dexLimit) {
$totalDex += ($subDex - $dexLimit) / 3;
$subDex = $dexLimit;
}
$totalDex += $subDex;
}
return $totalDex * $multiplier;
}
public function setInheritancePoint(General $general, InheritanceKey $key, $value, $aux = null) public function setInheritancePoint(General $general, InheritanceKey $key, $value, $aux = null)
{ {
+12 -1
View File
@@ -94,6 +94,13 @@ class ResetHelper{
} }
$hiddenSeed = bin2hex(random_bytes(16));//32byte, 128bit random seed $hiddenSeed = bin2hex(random_bytes(16));//32byte, 128bit random seed
if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1') {
$comparisonSeed = getenv('REF_HIDDEN_SEED');
if (!is_string($comparisonSeed) || !preg_match('/^[A-Za-z0-9._-]{1,128}$/D', $comparisonSeed)) {
throw new \RuntimeException('REF_HIDDEN_SEED is invalid for deterministic comparison install');
}
$hiddenSeed = $comparisonSeed;
}
$result = Util::generateFileUsingSimpleTemplate( $result = Util::generateFileUsingSimpleTemplate(
$servRoot.'/d_setting/UniqueConst.orig.php', $servRoot.'/d_setting/UniqueConst.orig.php',
@@ -320,7 +327,11 @@ class ResetHelper{
'server_cnt'=>$serverCnt, 'server_cnt'=>$serverCnt,
]; ];
foreach(RootDB::db()->query('SELECT `no`, `name`, `picture`, `imgsvr` FROM member WHERE grade >= 6') as $admin){ $comparisonInstall = getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1';
$adminMembers = $comparisonInstall
? []
: RootDB::db()->query('SELECT `no`, `name`, `picture`, `imgsvr` FROM member WHERE grade >= 6');
foreach($adminMembers as $admin){
$db->insert('general', [ $db->insert('general', [
'owner'=>$admin['no'], 'owner'=>$admin['no'],
'name'=>$admin['name'], 'name'=>$admin['name'],
+12 -5
View File
@@ -539,11 +539,8 @@ class GeneralBuilder{
return $this->death; return $this->death;
} }
public function build($env){ public static function prepareEnvironment(array $env): array
//scenario에 life==1인 경우 수명 제한이 없어지는 모양. {
if(!key_exists('stored_icons', $env)){ if(!key_exists('stored_icons', $env)){
try{ try{
$text = \file_get_contents(\sammo\ServConfig::getSharedIconPath('../hook/list.json?1')); $text = \file_get_contents(\sammo\ServConfig::getSharedIconPath('../hook/list.json?1'));
@@ -556,6 +553,16 @@ class GeneralBuilder{
$env['stored_icons'] = $storedIcons; $env['stored_icons'] = $storedIcons;
} }
return $env;
}
public function build($env){
//scenario에 life==1인 경우 수명 제한이 없어지는 모양.
$env = static::prepareEnvironment($env);
$isFictionMode = (Util::array_get($env['fiction'], 0)!=0); $isFictionMode = (Util::array_get($env['fiction'], 0)!=0);
$year = $env['year']; $year = $env['year'];
@@ -17,6 +17,15 @@ class SightseeingMessage{
const HeavyWounded = 0x2000; const HeavyWounded = 0x2000;
static protected $messages = null; static protected $messages = null;
static protected ?\sammo\RandUtil $comparisonRng = null;
public static function setComparisonRng(?\sammo\RandUtil $rng): void
{
if ($rng !== null && getenv('TURN_DIFFERENTIAL_ENABLED') !== '1') {
throw new \RuntimeException('comparison RNG is test-only');
}
static::$comparisonRng = $rng;
}
protected static function getMessageList():array{ protected static function getMessageList():array{
/* [[속성, [ /* [[속성, [
@@ -103,9 +112,15 @@ class SightseeingMessage{
} }
} }
public function pickAction():array{ public function pickAction(?\sammo\RandUtil $rng = null):array{
$actionRng = $rng ?? static::$comparisonRng;
if ($actionRng !== null) {
[$type, $texts] = $actionRng->choiceUsingWeightPair(static::$messages ?? []);
$text = $actionRng->choice($texts);
} else {
[$type, $texts] = Util::choiceRandomUsingWeightPair(static::$messages??[]); [$type, $texts] = Util::choiceRandomUsingWeightPair(static::$messages??[]);
$text = Util::choiceRandom($texts); $text = Util::choiceRandom($texts);
}
return [$type, $text]; return [$type, $text];
} }
+264
View File
@@ -8,6 +8,25 @@ use \Symfony\Component\Lock;
class TurnExecutionHelper class TurnExecutionHelper
{ {
/** @var array<string, int> */
private static array $comparisonActionCounts = [];
/** @var array<string, list<int>> */
private static array $comparisonActionGeneralIds = [];
/** @return array<string, int> */
public static function getComparisonActionCounts(): array
{
ksort(self::$comparisonActionCounts, SORT_STRING);
return self::$comparisonActionCounts;
}
/** @return array<string, list<int>> */
public static function getComparisonActionGeneralIds(): array
{
ksort(self::$comparisonActionGeneralIds, SORT_STRING);
return self::$comparisonActionGeneralIds;
}
/** @var General*/ /** @var General*/
protected $generalObj; protected $generalObj;
@@ -21,6 +40,18 @@ class TurnExecutionHelper
return max($completedTick, $candidateTick); return max($completedTick, $candidateTick);
} }
private static function recordInvaderProgress(KVStorage $gameStorage, int $beforeTick): void
{
if (Util::toInt($gameStorage->isunited) !== 1) {
return;
}
AutoResetProgress::recordIfAdvanced(
$gameStorage,
$beforeTick,
Util::toInt($gameStorage->turntime),
);
}
public function __destruct() public function __destruct()
{ {
$this->applyDB(); $this->applyDB();
@@ -247,13 +278,48 @@ class TurnExecutionHelper
$gameStor = KVStorage::getStorage($db, 'game_env'); $gameStor = KVStorage::getStorage($db, 'game_env');
$autorun_user = $gameStor->autorun_user; $autorun_user = $gameStor->autorun_user;
$traceCityChanges = PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& getenv('REF_AI_TRACE_CITY_CHANGES') === '1';
$observedCityStates = [];
if ($traceCityChanges) {
foreach ($db->query('SELECT city, nation, front, def, wall, pop, state, term, trust FROM city ORDER BY city') as $row) {
$observedCityStates[(int)$row['city']] = [
(int)$row['nation'],
(int)$row['front'],
(int)$row['def'],
(int)$row['wall'],
(int)$row['pop'],
(int)$row['state'],
(int)$row['term'],
(float)$row['trust'],
];
}
}
foreach ($generalsTodo as $rawGeneral) { foreach ($generalsTodo as $rawGeneral) {
if (getenv('REF_DETERMINISTIC_INSTALL_ENABLED') !== '1') {
$currActionTime = GameClock::readWallTime(); $currActionTime = GameClock::readWallTime();
if ($currActionTime > $limitActionTime) { if ($currActionTime > $limitActionTime) {
return [true, $currentTurn]; return [true, $currentTurn];
} }
}
$general = General::createObjFromDB($rawGeneral['no']); $general = General::createObjFromDB($rawGeneral['no']);
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& getenv('REF_AI_TRACE_SEQUENCE') === '1'
) {
fwrite(STDOUT, sprintf(
"TURN_START_REF id=%d nation=%d city=%d officer=%d turnTime=%s\n",
$general->getID(),
$general->getNationID(),
$general->getCityID(),
$general->getVar('officer_level'),
$general->getTurnTime()
));
}
$nationStor = KVStorage::getStorage($db, $general->getNationID(), 'nation_env'); $nationStor = KVStorage::getStorage($db, $general->getNationID(), 'nation_env');
$turnObj = new static($general); $turnObj = new static($general);
@@ -308,6 +374,23 @@ class TurnExecutionHelper
} }
if ($ai && ($general->getAuxVar('use_auto_nation_turn') ?? 1)) { if ($ai && ($general->getAuxVar('use_auto_nation_turn') ?? 1)) {
$nationCommandObj = $ai->chooseNationTurn($nationCommandObj); $nationCommandObj = $ai->chooseNationTurn($nationCommandObj);
$traceNationIds = getenv('REF_AI_TRACE_NATION_IDS');
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& is_string($traceNationIds)
&& in_array((string) $general->getID(), explode(',', $traceNationIds), true)
) {
fwrite(STDOUT, 'AI_NATION_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'nationId' => $general->getNationID(),
'year' => $year,
'month' => $month,
'action' => $nationCommandObj->getRawClassName(),
'args' => $nationCommandObj->getArg(),
'reason' => $nationCommandObj->reason,
]) . "\n");
}
$cityName = CityConst::byID($general->getCityID())->name; $cityName = CityConst::byID($general->getCityID())->name;
LogText("NationTurn", "General, {$general->getName()}, {$general->getID()}, {$cityName}, {$general->getStaticNation()['name']}, {$nationCommandObj->getBrief()}, {$nationCommandObj->reason}, "); LogText("NationTurn", "General, {$general->getName()}, {$general->getID()}, {$cityName}, {$general->getStaticNation()['name']}, {$nationCommandObj->getBrief()}, {$nationCommandObj->reason}, ");
} }
@@ -324,6 +407,18 @@ class TurnExecutionHelper
$nationCommandObj $nationCommandObj
); );
$nationStor->setValue($lastNationTurnKey, $resultNationTurn->toRaw()); $nationStor->setValue($lastNationTurnKey, $resultNationTurn->toRaw());
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& getenv('REF_AI_TRACE_SEQUENCE') === '1'
) {
fwrite(STDOUT, sprintf(
"ACTION_REF kind=nation actor=%d requested=%s resolved=%s\n",
$general->getID(),
$nationCommandObj->getRawClassName(),
$nationCommandObj->getRawClassName()
));
}
$general->setRawCity(null); $general->setRawCity(null);
} }
@@ -338,9 +433,50 @@ class TurnExecutionHelper
$autorunMode = true; $autorunMode = true;
$generalCommandObj = $newGeneralCommandObj; $generalCommandObj = $newGeneralCommandObj;
} }
$traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS');
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& is_string($traceGeneralIds)
&& in_array((string) $general->getID(), explode(',', $traceGeneralIds), true)
) {
fwrite(STDOUT, 'AI_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'year' => $year,
'month' => $month,
'npc' => $general->getNPCType(),
'cityId' => $general->getCityID(),
'gold' => $general->getVar('gold'),
'rice' => $general->getVar('rice'),
'affinity' => $general->getVar('affinity'),
'stats' => [
'leadership' => $general->getVar('leadership'),
'strength' => $general->getVar('strength'),
'intel' => $general->getVar('intel'),
],
'fullStats' => [
'leadership' => $general->getLeadership(false),
'strength' => $general->getStrength(false),
'intel' => $general->getIntel(false),
],
'action' => $generalCommandObj->getRawClassName(),
'args' => $generalCommandObj->getArg(),
'reason' => $generalCommandObj->reason,
]) . "\n");
}
$cityName = CityConst::byID($general->getCityID())->name; $cityName = CityConst::byID($general->getCityID())->name;
LogText("turn", "General, {$general->getName()}, {$general->getID()}, {$cityName}, {$general->getStaticNation()['name']}, {$generalCommandObj->getBrief()}, {$generalCommandObj->reason}, "); LogText("turn", "General, {$general->getName()}, {$general->getID()}, {$cityName}, {$general->getStaticNation()['name']}, {$generalCommandObj->getBrief()}, {$generalCommandObj->reason}, ");
} }
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& getenv('REF_AI_TRACE_SUMMARY') === '1'
) {
$actionName = $generalCommandObj->getRawClassName();
self::$comparisonActionCounts[$actionName] =
(self::$comparisonActionCounts[$actionName] ?? 0) + 1;
self::$comparisonActionGeneralIds[$actionName][] = $general->getID();
}
$rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize( $rng = new RandUtil(new LiteHashDRBG(Util::simpleSerialize(
UniqueConst::$hiddenSeed, UniqueConst::$hiddenSeed,
'generalCommand', 'generalCommand',
@@ -350,6 +486,80 @@ class TurnExecutionHelper
$generalCommandObj->getRawClassName() $generalCommandObj->getRawClassName()
))); )));
$turnObj->processCommand($rng, $generalCommandObj, $autorunMode); $turnObj->processCommand($rng, $generalCommandObj, $autorunMode);
if ($traceCityChanges) {
$changes = [];
foreach ($db->query('SELECT city, nation, front, def, wall, pop, state, term, trust FROM city ORDER BY city') as $row) {
$cityId = (int)$row['city'];
$next = [
(int)$row['nation'],
(int)$row['front'],
(int)$row['def'],
(int)$row['wall'],
(int)$row['pop'],
(int)$row['state'],
(int)$row['term'],
(float)$row['trust'],
];
if (($observedCityStates[$cityId] ?? null) !== $next) {
$changes[] = [
'id' => $cityId,
'before' => $observedCityStates[$cityId] ?? null,
'after' => $next,
];
$observedCityStates[$cityId] = $next;
}
}
if ($changes) {
fwrite(STDOUT, 'CITY_CHANGE_REF ' . Json::encode([
'actor' => $general->getID(),
'changes' => $changes,
]) . "\n");
}
}
$traceCityIds = array_values(array_filter(array_map(
'intval',
explode(',', (string)getenv('REF_AI_TRACE_CITY_IDS'))
)));
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& $traceCityIds
&& in_array((string)$general->getID(), explode(',', (string)getenv('REF_AI_TRACE_GENERAL_IDS')), true)
) {
fwrite(STDOUT, 'AI_CITY_STATE_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'cities' => $db->query(
'SELECT city, nation, front, supply, def, wall, state, term FROM city WHERE city IN %li ORDER BY city',
$traceCityIds
),
]) . "\n");
}
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& getenv('REF_AI_TRACE_SEQUENCE') === '1'
) {
fwrite(STDOUT, sprintf(
"ACTION_REF kind=general actor=%d requested=%s resolved=%s\n",
$general->getID(),
$generalCommandObj->getRawClassName(),
$generalCommandObj->getRawClassName()
));
}
$traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS');
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& is_string($traceGeneralIds)
&& in_array((string) $general->getID(), explode(',', $traceGeneralIds), true)
) {
fwrite(STDOUT, 'AI_GENERAL_POST_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'action' => $generalCommandObj->getRawClassName(),
'gold' => $general->getVar('gold'),
'rice' => $general->getVar('rice'),
]) . "\n");
}
} }
pullNationCommand($general->getVar('nation'), $general->getVar('officer_level')); pullNationCommand($general->getVar('nation'), $general->getVar('officer_level'));
pullGeneralCommand($general->getID()); pullGeneralCommand($general->getID());
@@ -365,7 +575,30 @@ class TurnExecutionHelper
} }
$turnObj->updateTurnTime(); $turnObj->updateTurnTime();
$traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS');
$traceCurrentGeneral =
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& is_string($traceGeneralIds)
&& in_array((string) $general->getID(), explode(',', $traceGeneralIds), true);
if ($traceCurrentGeneral) {
fwrite(STDOUT, 'AI_GENERAL_PRE_APPLY_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'gold' => $general->getVar('gold'),
'rice' => $general->getVar('rice'),
'updates' => $general->getUpdatedValues(),
]) . "\n");
}
$turnObj->applyDB(); $turnObj->applyDB();
if ($traceCurrentGeneral) {
fwrite(STDOUT, 'AI_GENERAL_DB_TRACE ' . Json::encode([
'generalId' => $general->getID(),
'stored' => $db->queryFirstRow(
'SELECT nation,city,gold,rice,crew,crewtype,train,atmos,leadership,strength,intel,leadership_exp,strength_exp,intel_exp,dex1,dex2,dex3,dex4,dex5 FROM general WHERE no=%i',
$general->getID()
),
]) . "\n");
}
} }
return [false, $currentTurn]; return [false, $currentTurn];
@@ -417,6 +650,8 @@ class TurnExecutionHelper
return $gameStor->turntime; return $gameStor->turntime;
} }
$completionTickBeforeRun = Util::toInt($gameStor->turntime);
$gameStor->cacheAll(); $gameStor->cacheAll();
// 1턴이상 갱신 없었으면 서버 지연 // 1턴이상 갱신 없었으면 서버 지연
checkDelay(); checkDelay();
@@ -462,6 +697,7 @@ class TurnExecutionHelper
$currentTurn, $currentTurn,
); );
} }
self::recordInvaderProgress($gameStor, $completionTickBeforeRun);
unlock(); unlock();
return $gameStor->turntime; return $gameStor->turntime;
} }
@@ -473,20 +709,47 @@ class TurnExecutionHelper
$gameStor->month $gameStor->month
))); )));
$traceMonthlyGeneral = static function (string $phase) use ($db): void {
$traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS');
if (
PHP_SAPI !== 'cli'
|| getenv('REF_DETERMINISTIC_INSTALL_ENABLED') !== '1'
|| !is_string($traceGeneralIds)
) {
return;
}
foreach (array_filter(explode(',', $traceGeneralIds), 'strlen') as $generalID) {
$stored = $db->queryFirstRow('SELECT gold,rice FROM general WHERE no=%i', (int)$generalID);
if ($stored) {
fwrite(STDOUT, 'AI_MONTH_PHASE_TRACE ' . Json::encode([
'phase' => $phase,
'generalId' => (int)$generalID,
'stored' => $stored,
]) . "\n");
}
}
};
$traceMonthlyGeneral('before-pre-month-event');
// 1달마다 처리하는 것들, 벌점 감소 및 건국,전턴,합병 -1, 군량 소모 // 1달마다 처리하는 것들, 벌점 감소 및 건국,전턴,합병 -1, 군량 소모
static::runEventHandler($db, $gameStor, EventTarget::PreMonth); static::runEventHandler($db, $gameStor, EventTarget::PreMonth);
$traceMonthlyGeneral('after-pre-month-event');
if (!preUpdateMonthly()) { if (!preUpdateMonthly()) {
unlock(); unlock();
throw new \RuntimeException('preUpdateMonthly() 처리 에러'); throw new \RuntimeException('preUpdateMonthly() 처리 에러');
} }
$traceMonthlyGeneral('after-pre-update');
turnDate($nextTurn); turnDate($nextTurn);
$traceMonthlyGeneral('after-turn-date');
// 분기계산. 장수들 턴보다 먼저 있다면 먼저처리 // 분기계산. 장수들 턴보다 먼저 있다면 먼저처리
if ($gameStor->month == 1) { if ($gameStor->month == 1) {
checkStatistic(); checkStatistic();
} }
static::runEventHandler($db, $gameStor, EventTarget::Month); static::runEventHandler($db, $gameStor, EventTarget::Month);
$traceMonthlyGeneral('after-month-event');
postUpdateMonthly($monthlyRng); postUpdateMonthly($monthlyRng);
$traceMonthlyGeneral('after-post-update');
// 다음달로 넘김 // 다음달로 넘김
$prevTurn = $nextTurn; $prevTurn = $nextTurn;
@@ -520,6 +783,7 @@ class TurnExecutionHelper
processTournament(); processTournament();
//거래 처리 //거래 처리
processAuction(); processAuction();
self::recordInvaderProgress($gameStor, $completionTickBeforeRun);
// 잠금 해제 // 잠금 해제
$turntime = $gameStor->turntime; $turntime = $gameStor->turntime;
+18
View File
@@ -120,6 +120,24 @@ class WarUnit
return $this->logActivatedSkill; return $this->logActivatedSkill;
} }
/**
* Comparison traces observe a phase before clearActivatedSkill() runs.
* Keep the product-facing cumulative accessor above unchanged, while
* exposing the canonical logged + currently-active view used by Core's
* WarBattleTraceUnitSnapshot.
*/
function getActivatedSkillTraceSnapshot(): array
{
$snapshot = $this->logActivatedSkill;
foreach ($this->activatedSkill as $skillName => $state) {
if (!$state) {
continue;
}
$snapshot[$skillName] = ($snapshot[$skillName] ?? 0) + 1;
}
return $snapshot;
}
function getRawNation(): array function getRawNation(): array
{ {
return $this->rawNation; return $this->rawNation;
+28
View File
@@ -371,6 +371,34 @@ class WarUnitGeneral extends WarUnit
function applyDB(\MeekroDB $db): bool function applyDB(\MeekroDB $db): bool
{ {
$traceGeneralIds = getenv('REF_AI_TRACE_GENERAL_IDS');
if (
PHP_SAPI === 'cli'
&& getenv('REF_DETERMINISTIC_INSTALL_ENABLED') === '1'
&& is_string($traceGeneralIds)
&& in_array((string) $this->getGeneral()->getID(), explode(',', $traceGeneralIds), true)
) {
$general = $this->getGeneral();
fwrite(STDOUT, 'AI_GENERAL_PRE_APPLY_TRACE ' . Json::encode([
'engine' => 'ref-war',
'generalId' => $general->getID(),
'stats' => [
'leadership' => $general->getVar('leadership'),
'strength' => $general->getVar('strength'),
'intelligence' => $general->getVar('intel'),
],
'meta' => [
'leadership_exp' => $general->getVar('leadership_exp'),
'strength_exp' => $general->getVar('strength_exp'),
'intel_exp' => $general->getVar('intel_exp'),
'dex1' => $general->getVar('dex1'),
'dex2' => $general->getVar('dex2'),
'dex3' => $general->getVar('dex3'),
'dex4' => $general->getVar('dex4'),
'dex5' => $general->getVar('dex5'),
],
]) . "\n");
}
$affected = $this->getGeneral()->applyDB($db); $affected = $this->getGeneral()->applyDB($db);
$this->getLogger()->flush(); $this->getLogger()->flush();
return $affected; return $affected;
+17 -9
View File
@@ -11,7 +11,7 @@ CREATE TABLE `general` (
`bornyear` INT(3) NULL DEFAULT '180', `bornyear` INT(3) NULL DEFAULT '180',
`deadyear` INT(3) NULL DEFAULT '300', `deadyear` INT(3) NULL DEFAULT '300',
`newmsg` INT(1) NULL DEFAULT '0', `newmsg` INT(1) NULL DEFAULT '0',
`picture` VARCHAR(40) NOT NULL, `picture` VARCHAR(64) NOT NULL,
`imgsvr` INT(1) NOT NULL DEFAULT '0', `imgsvr` INT(1) NOT NULL DEFAULT '0',
`name` VARCHAR(32) NOT NULL COLLATE 'utf8mb4_bin', `name` VARCHAR(32) NOT NULL COLLATE 'utf8mb4_bin',
`owner_name` VARCHAR(32) NULL DEFAULT NULL COLLATE 'utf8mb4_bin', `owner_name` VARCHAR(32) NULL DEFAULT NULL COLLATE 'utf8mb4_bin',
@@ -346,21 +346,29 @@ CREATE TABLE IF NOT EXISTS `emperior` (
`gold` INT(9) NULL DEFAULT '0', `gold` INT(9) NULL DEFAULT '0',
`rice` INT(9) NULL DEFAULT '0', `rice` INT(9) NULL DEFAULT '0',
`l12name` VARCHAR(64) NULL DEFAULT '', `l12name` VARCHAR(64) NULL DEFAULT '',
`l12pic` VARCHAR(32) NULL DEFAULT '', `l12pic` VARCHAR(64) NULL DEFAULT '',
`l12imgsvr` INT(1) NULL DEFAULT NULL,
`l11name` VARCHAR(64) NULL DEFAULT '', `l11name` VARCHAR(64) NULL DEFAULT '',
`l11pic` VARCHAR(32) NULL DEFAULT '', `l11pic` VARCHAR(64) NULL DEFAULT '',
`l11imgsvr` INT(1) NULL DEFAULT NULL,
`l10name` VARCHAR(64) NULL DEFAULT '', `l10name` VARCHAR(64) NULL DEFAULT '',
`l10pic` VARCHAR(32) NULL DEFAULT '', `l10pic` VARCHAR(64) NULL DEFAULT '',
`l10imgsvr` INT(1) NULL DEFAULT NULL,
`l9name` VARCHAR(64) NULL DEFAULT '', `l9name` VARCHAR(64) NULL DEFAULT '',
`l9pic` VARCHAR(32) NULL DEFAULT '', `l9pic` VARCHAR(64) NULL DEFAULT '',
`l9imgsvr` INT(1) NULL DEFAULT NULL,
`l8name` VARCHAR(64) NULL DEFAULT '', `l8name` VARCHAR(64) NULL DEFAULT '',
`l8pic` VARCHAR(32) NULL DEFAULT '', `l8pic` VARCHAR(64) NULL DEFAULT '',
`l8imgsvr` INT(1) NULL DEFAULT NULL,
`l7name` VARCHAR(64) NULL DEFAULT '', `l7name` VARCHAR(64) NULL DEFAULT '',
`l7pic` VARCHAR(32) NULL DEFAULT '', `l7pic` VARCHAR(64) NULL DEFAULT '',
`l7imgsvr` INT(1) NULL DEFAULT NULL,
`l6name` VARCHAR(64) NULL DEFAULT '', `l6name` VARCHAR(64) NULL DEFAULT '',
`l6pic` VARCHAR(32) NULL DEFAULT '', `l6pic` VARCHAR(64) NULL DEFAULT '',
`l6imgsvr` INT(1) NULL DEFAULT NULL,
`l5name` VARCHAR(64) NULL DEFAULT '', `l5name` VARCHAR(64) NULL DEFAULT '',
`l5pic` VARCHAR(32) NULL DEFAULT '', `l5pic` VARCHAR(64) NULL DEFAULT '',
`l5imgsvr` INT(1) NULL DEFAULT NULL,
`tiger` VARCHAR(128) NULL DEFAULT '', `tiger` VARCHAR(128) NULL DEFAULT '',
`eagle` VARCHAR(128) NULL DEFAULT '', `eagle` VARCHAR(128) NULL DEFAULT '',
`gen` TEXT NULL DEFAULT '', `gen` TEXT NULL DEFAULT '',
+44
View File
@@ -0,0 +1,44 @@
import { assert } from 'chai';
import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '../ts/gateway/loginFlow';
describe('automatic login failure flow', () => {
it('prompts for OTP without treating the challenge as a terminal login failure', () => {
assert.equal(
classifyAutoLoginFailure({
result: false,
silent: false,
reqOTP: true,
reason: '인증 코드를 입력해주세요',
}, 0),
'prompt_otp'
);
assert.equal(OTP_REQUIRED_MESSAGE, '인증 코드 입력이 필요합니다.');
});
it('keeps the existing retry and alert behavior for non-OTP failures', () => {
assert.equal(
classifyAutoLoginFailure({
result: false,
silent: false,
reason: '자동 로그인: 절차 오류',
}, 0),
'retry'
);
assert.equal(
classifyAutoLoginFailure({
result: false,
silent: false,
reason: '로그인할 수 없습니다.',
}, 1),
'alert'
);
assert.equal(
classifyAutoLoginFailure({
result: false,
silent: true,
reason: 'failed',
}, 1),
'silent'
);
});
});
+1
View File
@@ -36,5 +36,6 @@ export type AutoLoginResponse = {
export type AutoLoginFailed = { export type AutoLoginFailed = {
result: false, result: false,
silent: boolean, silent: boolean,
reqOTP?: boolean,
reason: string, reason: string,
} }
+9 -6
View File
@@ -69,11 +69,8 @@ function setupDBForm() {
$('#btn_random_generate_key').on('click', function (e) { $('#btn_random_generate_key').on('click', function (e) {
e.preventDefault(); e.preventDefault();
let token = ''; const bytes = crypto.getRandomValues(new Uint8Array(32));
while (token.length < 24) { const token = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
token += (Math.random() + 1).toString(36).substring(7);
}
token = token.substr(0, 24);
$('#image_request_key').val(token); $('#image_request_key').val(token);
}); });
@@ -86,6 +83,7 @@ function setupDBForm() {
serv_host: string, serv_host: string,
shared_icon_path: string, shared_icon_path: string,
game_image_path: string, game_image_path: string,
image_request_path: string,
image_request_key: string, image_request_key: string,
kakao_rest_key: string, kakao_rest_key: string,
kakao_admin_key: string, kakao_admin_key: string,
@@ -124,10 +122,14 @@ function setupDBForm() {
required: true, required: true,
type: 'string', type: 'string',
}, },
image_request_path: {
required: true,
type: 'string',
},
image_request_key: { image_request_key: {
required: false, required: false,
type: 'string', type: 'string',
min: 16, min: 32,
}, },
kakao_rest_key: { kakao_rest_key: {
required: false, required: false,
@@ -163,6 +165,7 @@ function setupDBForm() {
serv_host: values.serv_host, serv_host: values.serv_host,
shared_icon_path: values.shared_icon_path, shared_icon_path: values.shared_icon_path,
game_image_path: values.game_image_path, game_image_path: values.game_image_path,
image_request_path: values.image_request_path,
image_request_key: values.image_request_key, image_request_key: values.image_request_key,
kakao_rest_key: values.kakao_rest_key, kakao_rest_key: values.kakao_rest_key,
kakao_admin_key: values.kakao_admin_key, kakao_admin_key: values.kakao_admin_key,
+22 -19
View File
@@ -14,6 +14,7 @@ import '@/gateway/common';
import { isString } from 'lodash-es'; import { isString } from 'lodash-es';
import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI'; import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI';
import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login'; import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login';
import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '@/gateway/loginFlow';
declare global { declare global {
interface Window { interface Window {
getOAuthToken: (mode: string, scope_list: string[]) => void; getOAuthToken: (mode: string, scope_list: string[]) => void;
@@ -55,6 +56,17 @@ function resetToken() {
localStorage.removeItem(LOGIN_TOKEN_KEY); localStorage.removeItem(LOGIN_TOKEN_KEY);
} }
function showOTPModal() {
const modalEl = unwrap(document.querySelector('#modalOTP'));
if (!modalOTP) {
modalOTP = new Modal(modalEl);
modalEl.addEventListener('shown.bs.modal', function () {
unwrap(document.querySelector<HTMLElement>('#otp_code')).focus();
});
}
modalOTP.show();
}
async function tryAutoLogin() { async function tryAutoLogin() {
try { try {
const tokenInfo = getToken(); const tokenInfo = getToken();
@@ -93,13 +105,18 @@ async function tryAutoLogin() {
}, true); }, true);
if (!loginResult.result) { if (!loginResult.result) {
if (loginResult.reason === '자동 로그인: 절차 오류' && attempt === 0) { const failureAction = classifyAutoLoginFailure(loginResult, attempt);
if (failureAction === 'retry') {
console.warn('auto login failed by procedure error. retrying once.'); console.warn('auto login failed by procedure error. retrying once.');
await delay(150); await delay(150);
continue; continue;
} }
if (failureAction === 'prompt_otp') {
if (!loginResult.silent) { alert(OTP_REQUIRED_MESSAGE);
showOTPModal();
return;
}
if (failureAction === 'alert') {
alert(loginResult.reason); alert(loginResult.reason);
} }
console.error(loginResult.reason); console.error(loginResult.reason);
@@ -208,14 +225,7 @@ async function doLoginUsingOAuth() {
return; return;
} }
const modalEl = unwrap(document.querySelector('#modalOTP')) showOTPModal();
if (!modalOTP) {
modalOTP = new Modal(modalEl);
modalEl.addEventListener('shown.bs.modal', function () {
unwrap(document.querySelector<HTMLElement>('#otp_code')).focus();
});
}
modalOTP.show();
} }
function postOAuthResult(mode: string) { function postOAuthResult(mode: string) {
@@ -314,14 +324,7 @@ $(async function ($) {
return; return;
} }
const modalEl = unwrap(document.querySelector('#modalOTP')) showOTPModal();
if (!modalOTP) {
modalOTP = new Modal(modalEl);
modalEl.addEventListener('shown.bs.modal', function () {
unwrap(document.querySelector<HTMLElement>('#otp_code')).focus();
});
}
modalOTP.show();
}); });
$('#otp_form').on('submit', async function (e) { $('#otp_form').on('submit', async function (e) {
+18
View File
@@ -0,0 +1,18 @@
import type { AutoLoginFailed } from '@/defs/API/Login';
export const OTP_REQUIRED_MESSAGE = '인증 코드 입력이 필요합니다.';
export type AutoLoginFailureAction = 'retry' | 'prompt_otp' | 'alert' | 'silent';
export function classifyAutoLoginFailure(result: AutoLoginFailed, attempt: number): AutoLoginFailureAction {
if (result.reason === '자동 로그인: 절차 오류' && attempt === 0) {
return 'retry';
}
if (result.reqOTP) {
return 'prompt_otp';
}
if (result.silent) {
return 'silent';
}
return 'alert';
}
+24 -2
View File
@@ -78,14 +78,36 @@ if(!is_uploaded_file($image['tmp_name'])) {
break; break;
} }
if(!move_uploaded_file($image['tmp_name'], $dest)) { if (RemoteUserIconUploadClient::isConfiguredEnabled()) {
try {
$remoteName = bin2hex(random_bytes(16)).$newExt;
$contentType = image_type_to_mime_type($imageType);
RemoteUserIconUploadClient::uploadConfigured(
$remoteName,
$contentType,
(string)file_get_contents($image['tmp_name'])
);
$newPicName = "users/core/{$remoteName}";
$storedRemotely = true;
} catch (\Throwable $error) {
RemoteUserIconUploadClient::logFailure('user-icon', $error);
$storedRemotely = false;
}
} else {
$storedRemotely = null;
}
if($storedRemotely === false) {
$response['reason'] = '원격 이미지 저장소 업로드에 실패했습니다!';
$response['result'] = false;
} elseif($storedRemotely === null && !move_uploaded_file($image['tmp_name'], $dest)) {
$response['reason'] = '업로드에 실패했습니다!'; $response['reason'] = '업로드에 실패했습니다!';
$response['result'] = false; $response['result'] = false;
} else { } else {
$pic = "{$newPicName}?={$rf}"; $pic = "{$newPicName}?={$rf}";
RootDB::db()->update('member',[ RootDB::db()->update('member',[
'PICTURE' => $pic, 'PICTURE' => $pic,
'IMGSVR' => 1 'IMGSVR' => $storedRemotely === true ? 0 : 1
], 'NO=%i', $userID); ], 'NO=%i', $userID);
$servers = []; $servers = [];
+11 -13
View File
@@ -335,20 +335,18 @@ if ($server == $baseServerName) {
if (ServConfig::$imageRequestKey) { if (ServConfig::$imageRequestKey) {
try { try {
$imagePullPath = ServConfig::getImagePullURI(); $configuredPath = ServConfig::$imageRequestPath;
$pullResult = @file_get_contents($imagePullPath); $legacyPath = str_ends_with((string)parse_url($configuredPath, PHP_URL_PATH), '.php');
if ($pullResult === false) { $imageSyncPath = getenv('SAMMO_IMAGE_SYNC_URL')
throw new \ErrorException('Invalid URI'); ?: ($legacyPath ? 'https://sam-image.hided.net/v1/sync' : $configuredPath);
} $pullResult = ImageSyncClient::sync(
$pullResult = Json::decode($pullResult); $imageSyncPath,
if ($pullResult['result']) { 'core',
ServConfig::$imageRequestKey
);
$imgResult = true; $imgResult = true;
$imgDetail = $pullResult['version']; $imgDetail = $pullResult['lastSuccess']['commit'] ?? ($pullResult['changed'] ? 'updated' : 'current');
} else { } catch (\Throwable $e) {
$imgResult = false;
$imgDetail = $pullResult['reason'];
}
} catch (\Exception $e) {
$imgResult = false; $imgResult = false;
$imgDetail = $e->getMessage(); $imgDetail = $e->getMessage();
} }
+6
View File
@@ -0,0 +1,6 @@
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Deny from all
</IfModule>
+9 -5
View File
@@ -124,9 +124,17 @@ function fillTickColumn(
$nullSql = $nullable ? ' NULL DEFAULT NULL' : ' NOT NULL'; $nullSql = $nullable ? ' NULL DEFAULT NULL' : ' NOT NULL';
$db->query("ALTER TABLE %b ADD COLUMN %b BIGINT{$nullSql}", $table, $tickColumn); $db->query("ALTER TABLE %b ADD COLUMN %b BIGINT{$nullSql}", $table, $tickColumn);
$db->query( $db->query(
"UPDATE %b SET %b = (TIMESTAMPDIFF(MICROSECOND, %s, %b) * %i) DIV 1000000", "UPDATE %b SET %b = CASE "
. "WHEN YEAR(%b) >= 9000 THEN %i "
. "ELSE (TIMESTAMPDIFF(MICROSECOND, %s, %b) DIV 1000000) * %i "
. "+ (MOD(TIMESTAMPDIFF(MICROSECOND, %s, %b), 1000000) * %i) DIV 1000000 END",
$table, $table,
$tickColumn, $tickColumn,
$dateColumn,
GameClock::MAX_SAFE_TICK,
$baseTime,
$dateColumn,
$ticksPerSecond,
$baseTime, $baseTime,
$dateColumn, $dateColumn,
$ticksPerSecond, $ticksPerSecond,
@@ -206,10 +214,6 @@ try {
fillTickColumn($db, 'general_access_log', 'last_refresh', 'last_refresh_game_tick', $baseTimeString, $ticksPerSecond, true); fillTickColumn($db, 'general_access_log', 'last_refresh', 'last_refresh_game_tick', $baseTimeString, $ticksPerSecond, true);
fillTickColumn($db, 'message', 'time', 'time_game_tick', $baseTimeString, $ticksPerSecond, false); fillTickColumn($db, 'message', 'time', 'time_game_tick', $baseTimeString, $ticksPerSecond, false);
fillTickColumn($db, 'message', 'valid_until', 'valid_until_game_tick', $baseTimeString, $ticksPerSecond, false); fillTickColumn($db, 'message', 'valid_until', 'valid_until_game_tick', $baseTimeString, $ticksPerSecond, false);
$db->query(
'UPDATE message SET valid_until_game_tick = %i WHERE YEAR(valid_until) >= 9000',
GameClock::MAX_SAFE_TICK,
);
fillTickColumn($db, 'select_npc_token', 'valid_until', 'valid_until_game_tick', $baseTimeString, $ticksPerSecond, false); fillTickColumn($db, 'select_npc_token', 'valid_until', 'valid_until_game_tick', $baseTimeString, $ticksPerSecond, false);
fillTickColumn($db, 'select_npc_token', 'pick_more_from', 'pick_more_from_game_tick', $baseTimeString, $ticksPerSecond, false); fillTickColumn($db, 'select_npc_token', 'pick_more_from', 'pick_more_from_game_tick', $baseTimeString, $ticksPerSecond, false);
fillTickColumn($db, 'select_pool', 'reserved_until', 'reserved_until_game_tick', $baseTimeString, $ticksPerSecond, true); fillTickColumn($db, 'select_pool', 'reserved_until', 'reserved_until_game_tick', $baseTimeString, $ticksPerSecond, true);
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use sammo\DB;
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit(1);
}
$options = getopt('', ['help', 'server:', 'status', 'apply', 'backup:', 'server-closed']);
if (isset($options['help'])) {
pictureMigrationUsage();
}
if (isset($options['status']) === isset($options['apply'])) {
pictureMigrationUsage(2);
}
$server = $options['server'] ?? null;
if (!is_string($server) || preg_match('/^[a-z][a-z0-9_-]*$/', $server) !== 1) {
fwrite(STDERR, "--server must name one game-server directory, for example che, kwe, or hwe.\n");
exit(2);
}
$projectRoot = dirname(__DIR__);
$serverDirectory = $projectRoot . '/' . $server;
foreach (['lib.php', 'func.php', 'd_setting/DB.php'] as $requiredFile) {
if (!is_file($serverDirectory . '/' . $requiredFile)) {
fwrite(STDERR, "Server directory '$server' is not a configured game server: missing $requiredFile.\n");
exit(2);
}
}
$_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
$_SERVER['REQUEST_URI'] ??= "/cli/migrate-general-picture/$server";
require $serverDirectory . '/lib.php';
require $serverDirectory . '/func.php';
/** @return never */
function pictureMigrationUsage(int $exitCode = 0): void
{
$stream = $exitCode === 0 ? STDOUT : STDERR;
fwrite($stream, <<<'TEXT'
Usage:
php scripts/migrate-general-picture.php --server=PREFIX --status
php scripts/migrate-general-picture.php --server=PREFIX --apply --server-closed --backup=/absolute/path/to/pre-migration.sql
PREFIX is one configured game directory such as che, kwe, or hwe. Run status,
backup, apply, and verification separately for every game database; this script
never loops over all servers implicitly.
--status is read-only. --apply widens general.picture and the eight emperior
chief picture columns to VARCHAR(64), adds nullable l12imgsvr through l5imgsvr,
and backfills only uniquely matched historical values from ng_old_generals.
It requires a pre-existing, non-empty SQL backup whenever a schema or data
change is needed. Stop web and daemon traffic before applying; MariaDB/Aria DDL
is not transactional. --apply requires --server-closed as an explicit operator
confirmation that web and daemon traffic has already been stopped. The script
does not acquire or alter the GAME lock; it only takes a separate MariaDB named
lock to prevent two picture migrations from running together. Unmatched or
ambiguous historical values remain NULL.
TEXT);
exit($exitCode);
}
/** @return list<int> */
function emperiorPictureLevels(): array
{
return [12, 11, 10, 9, 8, 7, 6, 5];
}
/** @return list<array{string, string, int}> */
function pictureMigrationColumns(): array
{
$columns = [['general', 'picture', 40]];
foreach (emperiorPictureLevels() as $level) {
$columns[] = ['emperior', "l{$level}pic", 32];
}
return $columns;
}
/** @return array<string, mixed>|null */
function migrationColumnInfo(\MeekroDB $db, string $table, string $field): ?array
{
$column = $db->queryFirstRow("SHOW COLUMNS FROM `$table` WHERE Field = %s", $field);
return is_array($column) ? $column : null;
}
function pictureColumnCapacity(\MeekroDB $db, string $table, string $field): ?int
{
$column = migrationColumnInfo($db, $table, $field);
if (!$column || !is_string($column['Type'] ?? null)) {
return null;
}
if (preg_match('/^varchar\((\d+)\)$/i', $column['Type'], $matches) !== 1) {
return null;
}
return (int)$matches[1];
}
function imgsvrColumnIsCompatible(\MeekroDB $db, int $level): bool
{
$column = migrationColumnInfo($db, 'emperior', "l{$level}imgsvr");
if (!$column || !is_string($column['Type'] ?? null)) {
return false;
}
return preg_match('/^(?:tinyint|smallint|mediumint|int|bigint)\(\d+\)(?: unsigned)?$/i', $column['Type']) === 1
&& strtoupper((string)($column['Null'] ?? '')) === 'YES';
}
function pictureMigrationState(\MeekroDB $db): string
{
$needsMigration = false;
foreach (pictureMigrationColumns() as [$table, $field, $legacyCapacity]) {
$capacity = pictureColumnCapacity($db, $table, $field);
if ($capacity === $legacyCapacity) {
$needsMigration = true;
continue;
}
if ($capacity === null || $capacity < 64) {
return 'unsupported';
}
}
foreach (emperiorPictureLevels() as $level) {
if (migrationColumnInfo($db, 'emperior', "l{$level}imgsvr") === null) {
$needsMigration = true;
continue;
}
if (!imgsvrColumnIsCompatible($db, $level)) {
return 'unsupported';
}
}
return $needsMigration ? 'legacy' : 'ready';
}
function imgsvrColumnsExist(\MeekroDB $db): bool
{
foreach (emperiorPictureLevels() as $level) {
if (migrationColumnInfo($db, 'emperior', "l{$level}imgsvr") === null) {
return false;
}
}
return true;
}
function unresolvedEmperiorImgsvrCount(\MeekroDB $db): ?int
{
if (!imgsvrColumnsExist($db)) {
return null;
}
$terms = array_map(
static fn(int $level): string => "(`l{$level}imgsvr` IS NULL)",
emperiorPictureLevels(),
);
$count = $db->queryFirstField('SELECT SUM(' . implode(' + ', $terms) . ') FROM emperior');
return $count === null ? 0 : (int)$count;
}
/** @return list<array{no: int|string, imgsvr: int|string}> */
function recoverableEmperiorImgsvrRows(\MeekroDB $db, int $level): array
{
$nameField = "l{$level}name";
$pictureField = "l{$level}pic";
$imgsvrField = "l{$level}imgsvr";
return $db->query(
"SELECT e.`no`, MIN(CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.imgsvr')), '-1') AS SIGNED)) AS `imgsvr`
FROM `emperior` e
JOIN `ng_old_generals` og
ON og.`server_id` = e.`server_id`
AND og.`name` = e.`$nameField`
AND SUBSTRING_INDEX(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.picture')), ''), '?=', 1)
= SUBSTRING_INDEX(COALESCE(e.`$pictureField`, ''), '?=', 1)
AND CAST(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(og.`data`, '$.officer_level')), '-1') AS SIGNED) = %i
WHERE e.`$imgsvrField` IS NULL
GROUP BY e.`no`
HAVING COUNT(*) = 1 AND `imgsvr` IN (0, 1)",
$level,
);
}
function recoverableEmperiorImgsvrCount(\MeekroDB $db): int
{
if (!imgsvrColumnsExist($db)) {
return 0;
}
$count = 0;
foreach (emperiorPictureLevels() as $level) {
$count += count(recoverableEmperiorImgsvrRows($db, $level));
}
return $count;
}
function backfillEmperiorImgsvr(\MeekroDB $db): int
{
$updated = 0;
foreach (emperiorPictureLevels() as $level) {
$field = "l{$level}imgsvr";
foreach (recoverableEmperiorImgsvrRows($db, $level) as $row) {
$db->update(
'emperior',
[$field => (int)$row['imgsvr']],
"`no`=%i AND `$field` IS NULL",
(int)$row['no'],
);
$updated++;
}
}
return $updated;
}
function printPictureMigrationStatus(\MeekroDB $db, string $server): string
{
$state = pictureMigrationState($db);
printf("server=%s\n", $server);
printf("schema_state=%s\n", $state);
foreach (pictureMigrationColumns() as [$table, $field]) {
$capacity = pictureColumnCapacity($db, $table, $field);
$statusKey = $table === 'general' && $field === 'picture'
? 'picture_capacity'
: "{$table}_{$field}_capacity";
printf("%s=%s\n", $statusKey, $capacity ?? 'unknown');
}
foreach (emperiorPictureLevels() as $level) {
$field = "l{$level}imgsvr";
$column = migrationColumnInfo($db, 'emperior', $field);
printf(
"emperior_%s=%s\n",
$field,
$column === null ? 'missing' : strtolower((string)$column['Type']),
);
}
$unresolved = unresolvedEmperiorImgsvrCount($db);
printf("unresolved_emperior_imgsvr=%s\n", $unresolved ?? 'unknown');
return $state;
}
function requirePictureMigrationBackup(mixed $backup): string
{
if (!is_string($backup) || $backup === '' || $backup[0] !== '/' || !is_file($backup) || filesize($backup) === 0) {
fwrite(STDERR, "--backup must name a pre-existing, non-empty absolute SQL backup made immediately before migration.\n");
exit(2);
}
return $backup;
}
function acquirePictureMigrationLock(\MeekroDB $db, string $server): bool
{
return (int)$db->queryFirstField(
'SELECT GET_LOCK(%s, 0)',
"sammo-picture-migration-$server",
) === 1;
}
function releasePictureMigrationLock(\MeekroDB $db, string $server): void
{
$db->queryFirstField(
'SELECT RELEASE_LOCK(%s)',
"sammo-picture-migration-$server",
);
}
$db = DB::db();
if (isset($options['status'])) {
exit(printPictureMigrationStatus($db, $server) === 'unsupported' ? 2 : 0);
}
$state = pictureMigrationState($db);
if ($state === 'unsupported') {
fwrite(STDERR, "One or more picture columns have an unsupported schema; inspect --status first.\n");
exit(2);
}
$recoverableBefore = $state === 'ready' ? recoverableEmperiorImgsvrCount($db) : 0;
if ($state === 'ready' && $recoverableBefore === 0) {
fwrite(STDOUT, "Picture schema for $server is ready and no deterministic IMGSVR backfill candidates remain; nothing to do.\n");
printPictureMigrationStatus($db, $server);
exit(0);
}
requirePictureMigrationBackup($options['backup'] ?? null);
$serverClosed = isset($options['server-closed']);
if (!$serverClosed) {
fwrite(
STDERR,
"WARNING: Picture migration must run while $server web and daemon traffic is stopped. After stopping them, rerun with --server-closed.\n",
);
exit(3);
}
fwrite(
STDERR,
"WARNING: --server-closed is an operator confirmation; this script cannot verify that $server web and daemon traffic is stopped.\n",
);
if (!acquirePictureMigrationLock($db, $server)) {
fwrite(STDERR, "Another picture migration is already running for $server.\n");
exit(3);
}
$backfilled = 0;
try {
if (pictureColumnCapacity($db, 'general', 'picture') === 40) {
$db->query('ALTER TABLE general MODIFY picture VARCHAR(64) NOT NULL');
}
$emperiorClauses = [];
foreach (emperiorPictureLevels() as $level) {
$pictureField = "l{$level}pic";
$imgsvrField = "l{$level}imgsvr";
if (pictureColumnCapacity($db, 'emperior', $pictureField) === 32) {
$emperiorClauses[] = "MODIFY `$pictureField` VARCHAR(64) NULL DEFAULT ''";
}
if (migrationColumnInfo($db, 'emperior', $imgsvrField) === null) {
$emperiorClauses[] = "ADD COLUMN `$imgsvrField` INT(1) NULL DEFAULT NULL AFTER `$pictureField`";
}
}
if ($emperiorClauses !== []) {
$db->query('ALTER TABLE emperior ' . implode(', ', $emperiorClauses));
}
$backfilled = backfillEmperiorImgsvr($db);
} finally {
releasePictureMigrationLock($db, $server);
}
if (printPictureMigrationStatus($db, $server) !== 'ready') {
fwrite(STDERR, "Picture-column migration verification failed; restore the supplied backup.\n");
exit(4);
}
printf("Picture-column migration for %s completed; backfilled_imgsvr=%d.\n", $server, $backfilled);
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace sammo;
require dirname(__DIR__) . '/vendor/autoload.php';
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This command is CLI-only.\n");
exit(2);
}
$url = getenv('IMAGE_SYNC_URL') ?: 'https://sam-image.hided.net/v1/sync';
$secretFile = getenv('IMAGE_SYNC_SECRET_FILE') ?: '/run/secrets/image_sync_core_secret';
if (!is_file($secretFile)) {
fwrite(STDERR, "IMAGE_SYNC_SECRET_FILE is not readable.\n");
exit(2);
}
$secret = trim(file_get_contents($secretFile));
$result = ImageSyncClient::sync($url, 'core', $secret, $argv[1] ?? null);
fwrite(STDOUT, Json::encode($result) . PHP_EOL);
+21 -9
View File
@@ -6,20 +6,28 @@ phases AS (
g.winner_nation, g.winner_nation,
e.l12name, e.l12name,
e.l12pic, e.l12pic,
e.l12imgsvr,
e.l11name, e.l11name,
e.l11pic, e.l11pic,
e.l11imgsvr,
e.l10name, e.l10name,
e.l10pic, e.l10pic,
e.l10imgsvr,
e.l9name, e.l9name,
e.l9pic, e.l9pic,
e.l9imgsvr,
e.l8name, e.l8name,
e.l8pic, e.l8pic,
e.l8imgsvr,
e.l7name, e.l7name,
e.l7pic, e.l7pic,
e.l7imgsvr,
e.l6name, e.l6name,
e.l6pic, e.l6pic,
e.l6imgsvr,
e.l5name, e.l5name,
e.l5pic e.l5pic,
e.l5imgsvr
FROM emperior e FROM emperior e
LEFT JOIN ng_games g ON g.server_id = e.server_id LEFT JOIN ng_games g ON g.server_id = e.server_id
WHERE e.no BETWEEN 1 AND 99 WHERE e.no BETWEEN 1 AND 99
@@ -63,21 +71,21 @@ selection_reasons AS (
WHERE hall_rank <= 10 WHERE hall_rank <= 10
), ),
chief_slots AS ( chief_slots AS (
SELECT phase_no, server_id, winner_nation, 12 AS officer_level, l12name AS name, l12pic AS picture FROM phases SELECT phase_no, server_id, winner_nation, 12 AS officer_level, l12name AS name, l12pic AS picture, l12imgsvr AS imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 11, l11name, l11pic FROM phases SELECT phase_no, server_id, winner_nation, 11, l11name, l11pic, l11imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 10, l10name, l10pic FROM phases SELECT phase_no, server_id, winner_nation, 10, l10name, l10pic, l10imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 9, l9name, l9pic FROM phases SELECT phase_no, server_id, winner_nation, 9, l9name, l9pic, l9imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 8, l8name, l8pic FROM phases SELECT phase_no, server_id, winner_nation, 8, l8name, l8pic, l8imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 7, l7name, l7pic FROM phases SELECT phase_no, server_id, winner_nation, 7, l7name, l7pic, l7imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 6, l6name, l6pic FROM phases SELECT phase_no, server_id, winner_nation, 6, l6name, l6pic, l6imgsvr FROM phases
UNION ALL UNION ALL
SELECT phase_no, server_id, winner_nation, 5, l5name, l5pic FROM phases SELECT phase_no, server_id, winner_nation, 5, l5name, l5pic, l5imgsvr FROM phases
), ),
chief_reasons AS ( chief_reasons AS (
SELECT SELECT
@@ -94,6 +102,10 @@ chief_reasons AS (
'?=', '?=',
1 1
) = SUBSTRING_INDEX(COALESCE(c.picture, ''), '?=', 1) ) = SUBSTRING_INDEX(COALESCE(c.picture, ''), '?=', 1)
AND (
c.imgsvr IS NULL
OR CAST(COALESCE(JSON_VALUE(og.data, '$.imgsvr'), -1) AS SIGNED) = c.imgsvr
)
AND ( AND (
CAST(COALESCE(JSON_VALUE(og.data, '$.officer_level'), -1) AS SIGNED) = c.officer_level CAST(COALESCE(JSON_VALUE(og.data, '$.officer_level'), -1) AS SIGNED) = c.officer_level
OR ( OR (
+38 -3
View File
@@ -19,6 +19,30 @@ class LoginByToken extends LoginByID
{ {
static array $sensitiveArgs = ['hashedToken']; static array $sensitiveArgs = ['hashedToken'];
public static function kakaoFailureResponse(bool $reqOTP, string $reason): string | array
{
if (!$reqOTP) {
return $reason;
}
return [
'result' => false,
'silent' => false,
'reqOTP' => true,
'reason' => $reason,
];
}
public static function shouldDiscardTokenAfterKakaoFailure(bool $reqOTP): bool
{
return !$reqOTP;
}
public static function pendingSessionTokenID(bool $reqOTP, int $tokenID): ?int
{
return $reqOTP ? $tokenID : null;
}
public function getRequiredSessionMode(): int public function getRequiredSessionMode(): int
{ {
return \sammo\BaseAPI::NO_LOGIN; return \sammo\BaseAPI::NO_LOGIN;
@@ -117,13 +141,24 @@ class LoginByToken extends LoginByID
if ($userInfo['oauth_type'] == 'KAKAO') { if ($userInfo['oauth_type'] == 'KAKAO') {
$oauthFailResult = KakaoUtil::kakaoOAuthCheck($userInfo); $oauthFailResult = KakaoUtil::kakaoOAuthCheck($userInfo);
if ($oauthFailResult !== null) { if ($oauthFailResult !== null) {
$session->login($userInfo['no'], $userInfo['name'], $userInfo['grade'], true, $userInfo['token_valid_until'], null, Json::decode($userInfo['acl'] ?? '{}'));
[$oauthReqOTP, $oauthFailReason] = $oauthFailResult; [$oauthReqOTP, $oauthFailReason] = $oauthFailResult;
$session->login(
$userInfo['no'],
$userInfo['name'],
$userInfo['grade'],
true,
$userInfo['token_valid_until'],
static::pendingSessionTokenID($oauthReqOTP, $token_id),
Json::decode($userInfo['acl'] ?? '{}')
);
if (static::shouldDiscardTokenAfterKakaoFailure($oauthReqOTP)) {
$RootDB->delete( $RootDB->delete(
'login_token', 'login_token',
'id = %i', $token_id 'id = %i',
$token_id
); );
return $oauthFailReason; }
return static::kakaoFailureResponse($oauthReqOTP, $oauthFailReason);
} }
} }

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