Compare commits

...

27 Commits

Author SHA1 Message Date
de2f9439a7 bug修复 2026-06-26 16:36:01 +08:00
65a7b40a8c docs: capture achievement naming style 2026-06-26 16:30:04 +08:00
916d32a9fd ui: add library detail navigation 2026-06-26 16:28:20 +08:00
4d2f9d02b1 Add readable Kasen icon drafts 2026-06-26 16:24:43 +08:00
3daf841ee4 Fix Aunn twin petrify after portal move 2026-06-26 16:06:40 +08:00
0630a55b64 Add Kasen beast guide icon drafts 2026-06-26 16:04:49 +08:00
edf0c64fb0 fix dashboard skill save index drift 2026-06-26 15:34:24 +08:00
4e62cbe301 docs: summarize hero achievement naming patterns 2026-06-26 15:28:50 +08:00
1ae75092a3 Fix Valkyrie death heal ally check 2026-06-26 15:28:10 +08:00
8d79a24016 docs: add achievement design skill 2026-06-26 15:11:51 +08:00
15353c6821 Allow Kasen hero selection 2026-06-26 14:19:54 +08:00
471d486b80 fix map start guarantees near towers 2026-06-26 14:11:17 +08:00
37fb706f46 V0.7.4f2 2026-06-26 13:57:48 +08:00
75dbdc17dc Fix Suika falling splash animation fragment 2026-06-26 04:28:59 +08:00
1162961430 Fix Suika falling splash landing animation 2026-06-26 03:42:12 +08:00
494f2ce58f Document knight hero attack standard 2026-06-26 03:18:32 +08:00
14120bbd23 Translate new multilingual rows 2026-06-26 01:25:15 +08:00
ba1e6230e4 Shorten Hakurei library descriptions 2026-06-25 23:50:37 +08:00
0f3e5bba35 chore: refresh graphify reports 2026-06-25 23:23:24 +08:00
3995b75e6f fix: guard Unity asset references 2026-06-25 23:22:01 +08:00
c38fad9e32 docs: tighten hero state contract rules 2026-06-25 23:18:15 +08:00
79ac873c77 fix: validate Aunn skill data integrity 2026-06-25 23:15:55 +08:00
56f9eef41d docs: add art import and release strategy notes 2026-06-25 23:07:14 +08:00
8f9d23e0cf feat: add Hakurei Aunn content update 2026-06-25 23:05:30 +08:00
8034c08833 chore: add TH1 regression guardrails 2026-06-25 23:00:35 +08:00
65a937ccec chore: enforce proactive codex commits 2026-06-25 22:55:21 +08:00
b5fb03970a V0.7.4e 2026-06-25 02:50:59 +08:00
236 changed files with 271162 additions and 10878 deletions

View File

@ -0,0 +1,107 @@
---
name: th1-achievement-design
description: TH1 project workflow for designing, auditing, or rewriting achievement names and copy, especially wonder/civilization achievement tiers in Achievement.asset. Use when Codex needs to create TH1 成就命名, 奇观成就, 图鉴星星成就, civilization achievement sets, or derive achievement names from historical anecdotes, famous people, memorable events, or touching stories.
---
# TH1 Achievement Design
## Source Of Truth
Use this skill for achievement concept and naming work under `Unity/Assets/BundleResources/DataAssets/Achievement.asset`.
Do not edit generated export/localization outputs such as `Unity/Assets/BundleResources/Export/*`, `Tools/Multilingual.xlsx`, or `Tools/MultilingualTxt.txt` unless the user explicitly asks for export/import workflow changes.
When designing wonder achievements, also inspect `Unity/Assets/BundleResources/DataAssets/LibraryDataAssets.asset` for the wonder name, civilization, category, and `AchivePreId`.
## Workflow
1. Read the target achievement rows in `Achievement.asset` and nearby comparable rows.
2. Read the target wonder or civilization rows in `LibraryDataAssets.asset` when applicable.
3. Map achievement IDs before naming:
- `BigID: 3` is the wonder achievement group.
- `SmallID` matches the last two digits of `LibraryWonderData.AchivePreId`.
- Wonder achievement tiers usually use `InternalID` 1/2/3 with targets 10/20/30.
4. Identify at least three historical hooks per wonder before proposing tier names.
5. Prefer names that encode a story, person, quote, or surprising fact, not just a descriptive label.
6. Keep game-facing text concise enough for UI labels.
## Wonder Achievement Naming
For a three-tier wonder achievement set, make every tier carry a distinct anecdote:
- Tier 10: accessible place/object hook, founder, builder, visible landmark, or immediately recognizable feature.
- Tier 20: deeper cultural or human story, including reforms, travel, scholarship, rituals, love, loyalty, exile, rivalry, or faith transition.
- Tier 30: strongest remembered event, tragedy, mythic afterlife, famous quote, legacy, rediscovery, or historical turn.
Use the existing first four empire style as the baseline:
- Names are short Chinese titles, usually 4-12 characters, but can be longer for famous quotes or deliberate jokes.
- A good name often hides a one-sentence explanation behind it: `最后一位图书管理员`, `你去你的凡尔赛`, `柏林墙的幽灵`, `玄奘访学`, `我见她在白雾中微笑`.
- Mix tones across a civilization set: serious, poetic, witty, tragic, and warm names can coexist.
- Use famous people by name when the person is the hook: ruler, scholar, architect, traveler, poet, musician, reformer, general, explorer, or mythic figure.
- Avoid three names that all say the same thing with different nouns, such as three geographic labels.
- Avoid opaque proper nouns unless the name still has rhythm or the surrounding set clearly supports it.
- Do not stuff facts into the name. The name should be a hook, not a summary.
- Prefer names that feel like a player-facing achievement: a small joke, a quoted attitude, a strange image, a cute overstatement, or a clean emotional sting.
- If a name reads like encyclopedia prose, rewrite it. `你去你的凡尔赛`, `我能拍照吗`, `努比亚你怕不怕?`, and `我见她在白雾中微笑` are better models than literal labels such as `法律石前的诸首领`.
Use these naming grammars from the existing first four empires:
- **Spoken joke or jab**: `你去你的凡尔赛`, `努比亚你怕不怕?`, `我能拍照吗`, `谁才是真索邦?`. Use when a place/person has a natural punchline or attitude.
- **Short visual frame**: `我见她在白雾中微笑`, `博登湖畔日夜轰鸣`, `地平线上的荷鲁斯`, `橡木桩上的明珠`. Use when the charm is a scene rather than a fact.
- **Life-story title**: `路德维希未竟之梦`, `天才王公辛格二世`, `玄奘访学`, `图特摩斯四世之梦`. Use for ruler, traveler, scholar, builder, or patron hooks.
- **Poetic compression**: `永恒泪珠`, `自由之竿`, `塞纳河的冠冕`, `西岱岛的凤凰`, `星辰四方`. Use to turn a monument into an emotional symbol.
- **Civilization nickname**: `万国货仓`, `拉丁心脏`, `浪漫主义的摇篮`, `世界第一住宿制大学`. Use only when the phrase feels like a sharp title, not a bland summary.
- **Memorable long line**: `人可以走,书留下`, `若有天堂,就是这里,就是这里,就是这里`. Use sparingly, usually once per set, for a famous quote or emotionally decisive moment.
For Hakurei/Norway wonder achievements, lean into Reimu-compatible tone: practical, slightly irreverent, lazy-but-sharp, money-aware, and casually sacred. Avoid over-modern meme phrasing when the history wants pathos; avoid museum-label phrasing when the history wants a joke.
## Hero Achievement Naming
Use this section for `Achievement.asset` rows with `BigID: 2`.
- `BigID: 2` is the hero achievement group.
- `SmallID` matches the last two digits of `LibraryGiantData.AchivePreId`.
- Hero achievement tiers usually use `InternalID` 1/2/3 with `TrainGiantCondition.TargetCount` 10/20/30.
- Every hero achievement name should encode a fun anecdote, memorable historical episode, touching story, quote, or character-specific joke. A plain identity label is weak unless the label itself carries a known story.
- The best names fuse the Touhou character and the historical prototype into one hook, so the player can infer both sides after reading the library entry.
- Before proposing a name, write the hidden one-sentence story in your notes. If no story can be stated, keep searching.
- Prefer variety inside one hero set: one accessible character hook, one historical anecdote, and one fused or emotionally stronger capstone.
- If a prototype name is ambiguous, choose and record the interpretation. For example, `沃尔夫斯坦` can point to Wulfstan II/Lupus for sermon and law-code hooks, or Wulfstan of Hedeby for travel hooks; do not mix both silently.
First four empire baseline:
| Empire | Heroes And Prototypes | Existing Story-Hook Pattern |
| --- | --- | --- |
| Egyptian / Scarlet | Remilia-Osiris, Patchouli-Cleopatra, Sakuya-Anubis, Flandre-Set, Meiling-Horus | Mansion jokes and Egyptian myth form a family tragedy arc: blood court, library/asthma, loyal afterlife guide, Set killing Osiris, Horus revenge. |
| French / Eientei | Kaguya-Napoleon, Eirin-Berthier, Tewi-Richelieu, Reisen-Lannes, Mokou-Bernadotte | Napoleon and marshal anecdotes are recast as Eientei comedy: Hundred Days plus five impossible requests, staff-route prescriptions, indulgence/luck scams, Arcole/Aspern blood and moon rabbits, Bernadotte's northern crown as phoenix rebirth. |
| Germany / Moriya | Kanako-Frederick II, Suwako-Queen Louise, Sanae-Bismarck, Aya-Clausewitz, Momiji-Teutonic Grand Master | Prussian/German statecraft is rewritten as Moriya faith-business satire: Miracle of Brandenburg, Sanssouci/Onbashira, potato king, Tilsit/Queen Louise, Blood and Iron, Realpolitik, Kulturkampf, fog of war/news, Northern Crusade, Tannenberg. |
| Indian / Komeiji | Satori-Rama, Koishi-Krishna, Orin-Arjuna, Utsuho-Karna, Yuugi-Indra | Epic and religious stories are moved underground: Ayodhya/exile, Krishna flute and Bhagavad Gita, Arjuna's Gandiva and Brihannala disguise, Karna's curse and wheel in mud, Indra/Vritra and the thousand-eye punishment. |
Use the existing first four empire rows as a baseline, but raise future hero naming toward explicit anecdote quality:
- A direct Touhou title such as `不动的大图书馆`, `完美潇洒的从者`, or `铁血风祝` works best when paired with the other two tiers carrying stronger historical stories.
- A historical noun phrase such as `奥西里斯之死`, `守矢王室的奇迹`, or `被诅咒的苏利耶之子` should point to a specific event, not only a mythology keyword.
- A joke name such as `这剂量没问题月球人都在吃`, `捏造也是新闻的一部分`, or `阿空记不住真言` is valid when it clearly reveals the character's voice and a prototype anecdote.
- A capstone should feel like the strongest remembered story: death, exile, miracle, betrayal, redemption, last loyalty, famous quote, or irreversible historical turn.
## Historical Hook Quality
Prefer hooks in this order:
1. Directly tied to the target wonder.
2. Directly tied to a famous person connected with that place.
3. Widely known anecdote, quote, reform, tragedy, discovery, or pilgrimage related to the place.
4. Broader civilization motif only when the wonder lacks a stronger local story.
Do not invent false history. If a hook is uncertain, mark it as tentative instead of presenting it as canon.
## Checks
Before finishing:
- Confirm every proposed achievement name maps to the correct `WonderLibrary` and `SmallID`.
- Confirm the three tiers are distinguishable without reading a long explanation.
- Confirm at least one tier in each wonder set references a named person or named group when historically appropriate.
- If editing `Achievement.asset`, preserve YAML serialization shape and update only intended `Name`/`Desc`/condition fields.
- If only raw DataAssets are edited, tell the user that Unity multilingual export/import must run to sync runtime `Export` assets.

View File

@ -0,0 +1,4 @@
interface:
display_name: "TH1 Achievement Design"
short_description: "Design TH1 achievements with historical anecdote hooks."
default_prompt: "Use $th1-achievement-design to design TH1 achievement names from wonder, hero, or civilization context."

View File

@ -0,0 +1,113 @@
---
name: th1-art-asset-import
description: TH1 project workflow for file-managing completed original art assets from D:\OneDrive\ono_Create\TH01\TH01_Design\2_美术设定\入版 into Unity/Assets/BundleResources/ArtResources. Use when the user says a TH1 美术素材, 原始美术, 入版素材, PNG/PSD, unit sprite, building/resource sprite, projectile, common UI/art asset, or similar art file is finished and should be found by name and copied into the TH1 Unity project.
---
# TH1 Art Asset Import
Use this skill when the user gives a completed art asset name and wants it brought into the TH1 Unity project.
## Source And Target
Original art source root:
`D:\OneDrive\ono_Create\TH01\TH01_Design\2_美术设定\入版`
Unity art target root:
`C:\TH1\TH1\Unity\Assets\BundleResources\ArtResources`
Initial source scan:
- File types: 1441 `.png`, 133 `.psd`, 106 `.meta`.
- Top-level source folders include `TH1Animals`, `TH1Buildings`, `TH1CharIllustrations`, `TH1Common`, `TH1Forests`, `TH1Fruits`, `TH1Grounds`, `TH1Miscs`, `TH1Mountains`, `TH1Projectile`, `TH1Units`, `TH1VFX`, and multiple `*Prepare` folders.
- Largest active prepare folder observed: `TH1UnitsPrepare` with 465 files.
## Default Workflow
1. Search the original art source by the user-provided name.
2. Prefer exact filename or exact stem matches. If multiple candidates exist, show the candidate list and choose deliberately; do not silently pick a legacy or unrelated file.
3. Prefer game-ready `.png` files for runtime import. Copy `.psd` only when the user asks to archive or inspect source working files.
4. Infer the target from an existing same-name asset under `ArtResources` when possible. This preserves the current folder structure and existing Unity `.meta`.
5. If no existing same-name target exists, map the source category to the target category and require an explicit target directory when the mapping is ambiguous.
6. Preserve existing Unity `.meta` files. Do not overwrite target `.meta` unless the user explicitly asks and there is a clear GUID/importer reason.
7. Do not edit `Unity/Assets/BundleResources/Export/*`, `Tools/Multilingual.xlsx`, or `Tools/MultilingualTxt.txt` during art-file copying.
8. If the asset also needs DataAsset wiring, enum additions, sprite import settings, or image normalization, use the narrower TH1 art skill after copying:
- Units: `th1-unit-sprite-style`
- Projectiles: `th1-projectile-art-style`
- Grid/resource/building/terrain art: `th1-grid-resource-building-art`
- Unit art creation/style review: `th1-lowpoly-unit-art-style` or `th1-unit-sprite-style`
## Source To Target Mapping
Default category mapping:
- `TH1Animals` and `TH1AnimalsPrepare` -> `ArtResources/TH1Animals`
- `TH1Buildings` -> `ArtResources/TH1Buildings`
- `TH1CharIllustrations` -> `ArtResources/TH1CharIllustrations`
- `TH1Common` -> `ArtResources/TH1Common`
- `TH1Forests` and `TH1ForestsPrepare` -> `ArtResources/TH1Forests`
- `TH1Fruits` and `TH1FruitsPrepare` -> `ArtResources/TH1Fruits`
- `TH1Grounds` and `TH1GroundsPrepare` -> `ArtResources/TH1Grounds`
- `TH1Miscs` and `TH1MiscsPrepare` -> `ArtResources/TH1Miscs`
- `TH1Mountains` and `TH1MountainsPrepare` -> `ArtResources/TH1Mountains`
- `TH1Projectile` -> `ArtResources/TH1Projectile`
- `TH1Units` and `TH1UnitsPrepare` -> `ArtResources/TH1Units`
- `TH1VFX` -> `ArtResources/TH1VFX`
- `TH1WatersPrepare` -> `ArtResources/TH1Waters`
Manual target selection required:
- `TH1TechPrepare`: likely related to `ArtResources/TH1UI/TechTree`, but do not infer automatically because source names such as `techItems...` do not match current target names directly.
- Any source folder not listed above.
- Any candidate under a `legacy` subfolder unless the user explicitly asks for a legacy asset.
For `TH1Units` and `TH1UnitsPrepare`, filenames matching `{Force}_{Civ}_{UnitType}.png` should normally land under:
`ArtResources/TH1Units/{Force}/{Civ}/`
Example:
`ReimuForces_Norway_LongBoat.png` -> `ArtResources/TH1Units/ReimuForces/Norway/ReimuForces_Norway_LongBoat.png`
## Script
Use the helper script from the repository root. It defaults to preview mode and writes nothing unless `--copy` is provided.
Preview candidates:
```powershell
python .codex/skills/th1-art-asset-import/scripts/import_art_asset.py "ReimuForces_Norway_LongBoat"
```
Restrict search to one source folder:
```powershell
python .codex/skills/th1-art-asset-import/scripts/import_art_asset.py "LongBoat" --category TH1UnitsPrepare
```
Copy one exact replacement while preserving any existing target `.meta`:
```powershell
python .codex/skills/th1-art-asset-import/scripts/import_art_asset.py "TH1Projectile_SanaeDivine.png" --exact --copy --overwrite
```
Copy a selected candidate to an explicit target directory:
```powershell
python .codex/skills/th1-art-asset-import/scripts/import_art_asset.py "techItems511_0.png" `
--exact `
--target-dir Unity/Assets/BundleResources/ArtResources/TH1UI/TechTree `
--copy
```
When several candidates are listed, rerun with `--index N` to select the numbered candidate. Use `--dest-name` only when the project target filename intentionally differs from the original source filename.
## Verification
After copying:
1. Run `git status --short` and inspect the copied files.
2. Confirm whether an existing `.meta` was preserved or a new `.meta` still needs Unity/importer handling.
3. If replacing a sprite referenced by DataAssets, confirm the DataAsset still points to the intended GUID.
4. If adding a new sprite reference, update source DataAssets under `Unity/Assets/BundleResources/DataAssets`, not generated export files.

View File

@ -0,0 +1,4 @@
interface:
display_name: "TH1 Art Asset Import"
short_description: "Import completed TH1 source art into Unity assets."
default_prompt: "Use $th1-art-asset-import to find a completed source art asset by name and import it into the TH1 Unity project."

View File

@ -0,0 +1,354 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import shutil
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
SOURCE_ROOT = Path(r"D:\OneDrive\ono_Create\TH01\TH01_Design\2_美术设定\入版")
SCRIPT_PATH = Path(__file__).resolve()
REPO_ROOT = SCRIPT_PATH.parents[4]
TARGET_ROOT = REPO_ROOT / "Unity" / "Assets" / "BundleResources" / "ArtResources"
CATEGORY_MAP = {
"TH1Animals": "TH1Animals",
"TH1AnimalsPrepare": "TH1Animals",
"TH1Buildings": "TH1Buildings",
"TH1CharIllustrations": "TH1CharIllustrations",
"TH1Common": "TH1Common",
"TH1Forests": "TH1Forests",
"TH1ForestsPrepare": "TH1Forests",
"TH1Fruits": "TH1Fruits",
"TH1FruitsPrepare": "TH1Fruits",
"TH1Grounds": "TH1Grounds",
"TH1GroundsPrepare": "TH1Grounds",
"TH1Miscs": "TH1Miscs",
"TH1MiscsPrepare": "TH1Miscs",
"TH1Mountains": "TH1Mountains",
"TH1MountainsPrepare": "TH1Mountains",
"TH1Projectile": "TH1Projectile",
"TH1Units": "TH1Units",
"TH1UnitsPrepare": "TH1Units",
"TH1VFX": "TH1VFX",
"TH1WatersPrepare": "TH1Waters",
"TH1TechPrepare": None,
}
AUTO_FLAT_CATEGORIES = {
"TH1Animals",
"TH1AnimalsPrepare",
"TH1CharIllustrations",
"TH1Common",
"TH1Forests",
"TH1ForestsPrepare",
"TH1Fruits",
"TH1FruitsPrepare",
"TH1Grounds",
"TH1GroundsPrepare",
"TH1Miscs",
"TH1MiscsPrepare",
"TH1Mountains",
"TH1MountainsPrepare",
"TH1Projectile",
"TH1VFX",
"TH1WatersPrepare",
}
@dataclass(frozen=True)
class Candidate:
path: Path
score: tuple
def normalize_exts(raw: str) -> set[str] | None:
value = raw.strip()
if value.lower() == "all":
return None
exts: set[str] = set()
for part in value.split(","):
part = part.strip().lower()
if not part:
continue
if not part.startswith("."):
part = "." + part
exts.add(part)
return exts
def is_inside(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
return True
except ValueError:
return False
def rel(path: Path, root: Path) -> str:
try:
return str(path.relative_to(root))
except ValueError:
return str(path)
def format_size(size: int) -> str:
if size >= 1024 * 1024:
return f"{size / (1024 * 1024):.1f} MB"
if size >= 1024:
return f"{size / 1024:.1f} KB"
return f"{size} B"
def format_mtime(path: Path) -> str:
return datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
def source_roots(categories: list[str] | None) -> list[Path]:
if categories:
roots = []
for category in categories:
root = SOURCE_ROOT / category
if not root.exists():
raise SystemExit(f"Source category not found: {root}")
roots.append(root)
return roots
return [SOURCE_ROOT]
def score_path(path: Path, query: str) -> tuple | None:
q = query.lower()
q_stem = Path(query).stem.lower()
name = path.name.lower()
stem = path.stem.lower()
if name == q:
match_score = 0
elif stem == q_stem:
match_score = 1
elif name.startswith(q):
match_score = 2
elif stem.startswith(q_stem):
match_score = 3
elif q in name:
match_score = 4
elif q_stem and q_stem in stem:
match_score = 5
else:
return None
legacy_penalty = 1 if any(part.lower() == "legacy" for part in path.parts) else 0
prepare_penalty = 0 if "prepare" in path.parts[0].lower() else 0
ext_penalty = 0 if path.suffix.lower() == ".png" else 1
newest_first = -path.stat().st_mtime
return (match_score, legacy_penalty, prepare_penalty, ext_penalty, newest_first, str(path).lower())
def find_candidates(query: str, categories: list[str] | None, exts: set[str] | None, exact: bool) -> list[Candidate]:
matches: list[Candidate] = []
query_lower = query.lower()
query_stem = Path(query).stem.lower()
query_has_ext = bool(Path(query).suffix)
for root in source_roots(categories):
for path in root.rglob("*"):
if not path.is_file():
continue
if exts is not None and path.suffix.lower() not in exts:
continue
score = score_path(path, query)
if score is None:
continue
if exact:
name = path.name.lower()
stem = path.stem.lower()
if query_has_ext:
if name != query_lower:
continue
elif stem != query_stem:
continue
matches.append(Candidate(path=path, score=score))
matches.sort(key=lambda item: item.score)
return matches
def print_candidates(candidates: list[Candidate], limit: int) -> None:
for index, candidate in enumerate(candidates[:limit], start=1):
path = candidate.path
legacy = " legacy" if any(part.lower() == "legacy" for part in path.parts) else ""
print(
f"[{index}] {rel(path, SOURCE_ROOT)}"
f" ({path.suffix.lower()[1:]}, {format_size(path.stat().st_size)}, {format_mtime(path)}{legacy})"
)
if len(candidates) > limit:
print(f"... {len(candidates) - limit} more candidates hidden; increase --limit to show them.")
def existing_targets(dest_name: str, target_category: str | None) -> list[Path]:
lower_name = dest_name.lower()
matches = [path for path in TARGET_ROOT.rglob("*") if path.is_file() and path.name.lower() == lower_name]
if target_category:
category_matches = []
for path in matches:
try:
first_part = path.relative_to(TARGET_ROOT).parts[0]
except (ValueError, IndexError):
continue
if first_part == target_category:
category_matches.append(path)
if category_matches:
matches = category_matches
return sorted(matches, key=lambda p: str(p).lower())
def infer_unit_target(source_path: Path, dest_name: str) -> Path | None:
parts = Path(dest_name).stem.split("_")
if len(parts) < 3:
return None
force, civ = parts[0], parts[1]
if not force.endswith("Forces"):
return None
return TARGET_ROOT / "TH1Units" / force / civ / dest_name
def infer_target(source_path: Path, dest_name: str, target_dir: str | None) -> Path:
if target_dir:
target = Path(target_dir)
if not target.is_absolute():
target = REPO_ROOT / target
target = target.resolve()
if not is_inside(target, TARGET_ROOT):
raise SystemExit(f"--target-dir must be inside {TARGET_ROOT}: {target}")
return target / dest_name
source_rel = source_path.relative_to(SOURCE_ROOT)
source_category = source_rel.parts[0]
target_category = CATEGORY_MAP.get(source_category)
existing = existing_targets(dest_name, target_category)
if len(existing) == 1:
return existing[0]
if len(existing) > 1:
print("Multiple existing target files match this name:")
for index, path in enumerate(existing, start=1):
print(f"[{index}] {rel(path, TARGET_ROOT)}")
raise SystemExit("Pass --target-dir to choose the intended target folder.")
if target_category is None:
raise SystemExit(f"No automatic target mapping for source category {source_category}; pass --target-dir.")
if source_category in {"TH1Units", "TH1UnitsPrepare"}:
target = infer_unit_target(source_path, dest_name)
if target:
return target
raise SystemExit("Could not infer TH1Units target from filename; pass --target-dir.")
if source_category in AUTO_FLAT_CATEGORIES and len(source_rel.parts) == 2:
return TARGET_ROOT / target_category / dest_name
raise SystemExit(
f"Source category {source_category} is nested or ambiguous for automatic import; pass --target-dir."
)
def copy_asset(source: Path, target: Path, overwrite: bool, copy_source_meta: bool, overwrite_meta: bool) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists() and not overwrite:
raise SystemExit(f"Target already exists; pass --overwrite to replace it: {target}")
shutil.copy2(source, target)
print(f"Copied: {rel(source, SOURCE_ROOT)} -> {rel(target, REPO_ROOT)}")
source_meta = Path(str(source) + ".meta")
target_meta = Path(str(target) + ".meta")
if copy_source_meta and source_meta.exists():
if target_meta.exists() and not overwrite_meta:
print(f"Skipped source .meta because target .meta exists: {rel(target_meta, REPO_ROOT)}")
else:
shutil.copy2(source_meta, target_meta)
print(f"Copied meta: {rel(source_meta, SOURCE_ROOT)} -> {rel(target_meta, REPO_ROOT)}")
elif target_meta.exists():
print(f"Preserved existing target .meta: {rel(target_meta, REPO_ROOT)}")
else:
print("No target .meta exists yet; Unity or a narrower art-import workflow should create/import it.")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Find and optionally copy completed TH1 original art assets into Unity ArtResources."
)
parser.add_argument("name", help="Asset filename, stem, or search term.")
parser.add_argument("--category", action="append", help="Restrict search to a source top-level folder.")
parser.add_argument("--extensions", default=".png,.psd", help="Comma-separated extensions, or 'all'.")
parser.add_argument("--exact", action="store_true", help="Only match exact filename or exact stem.")
parser.add_argument("--index", type=int, help="1-based candidate number to select after preview.")
parser.add_argument("--limit", type=int, default=30, help="Maximum candidates to print.")
parser.add_argument("--copy", action="store_true", help="Copy the selected candidate. Without this, preview only.")
parser.add_argument("--overwrite", action="store_true", help="Allow replacing an existing target asset file.")
parser.add_argument("--target-dir", help="Explicit target directory, absolute or relative to the repository root.")
parser.add_argument("--dest-name", help="Target filename. Defaults to the selected source filename.")
parser.add_argument("--allow-legacy", action="store_true", help="Allow copying a candidate from a legacy folder.")
parser.add_argument("--copy-source-meta", action="store_true", help="Also copy source sidecar .meta if present.")
parser.add_argument("--overwrite-meta", action="store_true", help="Allow --copy-source-meta to overwrite target .meta.")
return parser.parse_args()
def main() -> int:
args = parse_args()
if not SOURCE_ROOT.exists():
raise SystemExit(f"Source root not found: {SOURCE_ROOT}")
if not TARGET_ROOT.exists():
raise SystemExit(f"Target root not found: {TARGET_ROOT}")
if args.limit <= 0:
raise SystemExit("--limit must be positive.")
exts = normalize_exts(args.extensions)
candidates = find_candidates(args.name, args.category, exts, args.exact)
print(f"Source root: {SOURCE_ROOT}")
print(f"Target root: {TARGET_ROOT}")
print(f"Query: {args.name}")
print(f"Candidates: {len(candidates)}")
if not candidates:
return 1
print_candidates(candidates, args.limit)
selected: Candidate | None = None
if args.index is not None:
if args.index < 1 or args.index > len(candidates):
raise SystemExit(f"--index must be between 1 and {len(candidates)}.")
selected = candidates[args.index - 1]
elif len(candidates) == 1:
selected = candidates[0]
elif args.copy:
raise SystemExit("Multiple candidates found; rerun with --index N before copying.")
if selected is None:
return 0
source = selected.path
if any(part.lower() == "legacy" for part in source.parts) and not args.allow_legacy:
raise SystemExit("Selected source is under a legacy folder; pass --allow-legacy if that is intentional.")
dest_name = args.dest_name or source.name
target = infer_target(source, dest_name, args.target_dir)
print(f"Selected: {rel(source, SOURCE_ROOT)}")
print(f"Inferred target: {rel(target, REPO_ROOT)}")
if not args.copy:
print("Preview only. Add --copy to write the selected asset.")
return 0
copy_asset(source, target, args.overwrite, args.copy_source_meta, args.overwrite_meta)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,6 +1,6 @@
---
name: th1-base
description: TH1 project baseline engineering guide for Unity client changes: assembly boundaries, HybridCLR hotfix rules, YooAsset/AssetBundle resource rules, BundleResources DataAssets -> Export packaging sync, MemoryPack/AOT serialization safety, unified PC/iOS build packaging flow, and routing to domain skills. Use before broad TH1 Unity code/resource/build changes, especially when touching Scripts, asmdefs, HybridCLR, hotfix DLLs, StreamingAssets, BundleResources, Resources.Load replacement, YooAsset, MemoryPack, generated config, build panels, or package verification.
description: "TH1 project baseline engineering guide for Unity client changes: assembly boundaries, HybridCLR hotfix rules, YooAsset/AssetBundle resource rules, BundleResources DataAssets to Export packaging sync, MemoryPack/AOT serialization safety, unified PC/iOS build packaging flow, and routing to domain skills. Use before broad TH1 Unity code/resource/build changes, especially when touching Scripts, asmdefs, HybridCLR, hotfix DLLs, StreamingAssets, BundleResources, Resources.Load replacement, YooAsset, MemoryPack, generated config, build panels, or package verification."
---
# TH1 Base Engineering
@ -42,7 +42,7 @@ If multiple skills apply, use this skill first, then the narrow domain skill.
- Identify whether the change touches AOT shell, hotfix code, resources/AB, serialization, build pipeline, platform services, or gameplay behavior.
- For Unity code changes, inspect nearby asmdefs and namespace ownership before moving files.
- For generated files, find and update the generator/template instead of hand-editing output, unless the user explicitly asks for a one-off generated output patch.
- Do not modify MemoryPack compatibility, obfuscation behavior, or generated config formats without explicit confirmation.
- Do not modify MemoryPack compatibility, obfuscation behavior, or generated config formats without explicit confirmation. Exception: adding new `SkillType` / `SkillBase` classes may be done without extra confirmation when the user has approved the feature and all additions are append-only.
## Assembly Boundaries
@ -106,6 +106,8 @@ Rules:
- Do not reorder or remove existing `[MemoryPackInclude]` members in persisted/networked types. Append new fields.
- For old fields no longer used, keep compatibility placeholders when later fields would shift.
- Treat `MapData`, `NetData`, `GameRecord`, `GameRecordData`, config bytes, and network messages as compatibility-sensitive.
- For gameplay skill additions, append only: add new `SkillType` enum values after existing project skill values, add new `SkillBase` subclasses, add generated MemoryPackable/union entries at the end, and do not reuse, reorder, or delete existing skill ids or union ids. This append-only skill path does not require a separate MemoryPack confirmation after the feature design is approved.
- If a skill change needs to alter existing serialized skill fields, existing union ids, enum numeric values, saved field order, or non-skill persisted/network payloads, stop and ask for explicit confirmation.
- If generated config code needs a change, update `ExcelExport/ExportTemplate_*.txt` and regenerate/sync output.
## YooAsset And AB Rules

View File

@ -213,50 +213,50 @@
"giant_enum": "NorwayReimu",
"name": "博丽灵梦",
"subtitle": "帝国的御札巫女",
"desc": "临时图鉴文案。可向友方施加<color=green>博丽加护</color>抵消伤害,并以<color=red>退治</color>标记敌方英雄,通过连动攻击与御札效果获得额外收益。",
"diag": "暂未设置",
"desc": "饰演<color=red>克努特大帝</color>的博丽巫女。她原本只是为了奖金和可疑黑幕参加比赛,直到抵达赛场后,才发现本该留在神社看家的阿吽也站在队伍里。",
"diag": "我不是让你看家吗?……堇子,你最好解释一下。",
"english_name": "REIMU HAKUREI",
"achieve_pre_id": 0
"achieve_pre_id": 221
},
{
"giant_type": 22,
"giant_enum": "NorwaySumireko",
"name": "宇佐见堇子",
"subtitle": "帝国的灵异使者",
"desc": "临时图鉴文案。可在战场上发射不同地形的灵异珠,影响附近单位的移动、攻击、防御与承伤规则。",
"diag": "暂未设置",
"desc": "饰演<color=red>诺曼底的爱玛</color>的女高中生。为了追寻灵异现象,她告诉阿吽“比赛非常需要你这样出色的狛犬来守护,而且守护规模是博丽神社的一百倍”,成功获得了可靠的保镖。",
"diag": "这个游戏非常可疑充斥着各种灵异现象……什么你说这个叫“bug”",
"english_name": "SUMIREKO USAMI",
"achieve_pre_id": 0
"achieve_pre_id": 222
},
{
"giant_type": 23,
"giant_enum": "NorwayKasen",
"name": "茨木华扇",
"subtitle": "帝国的兽引仙人",
"desc": "临时图鉴文案。可设置全场唯一的<color=green>兽引</color>作为战术支点,通过召回、减伤、回复与传送能力支援战线。",
"diag": "暂未设置",
"subtitle": "帝国的独臂仙人",
"desc": "饰演<color=red>沃尔夫斯坦</color>的独臂仙人。为了阻止巫女沉迷可疑的游戏,她连续数日亲自研究规则、检查阵容、推演棋盘。她坚称这只是必要的调查,绝不是因为再打一局就能赢。",
"diag": "监督者当然要熟悉规则。容我再研究一局,应该就能确认问题所在了。",
"english_name": "KASEN IBARAKI",
"achieve_pre_id": 0
"achieve_pre_id": 223
},
{
"giant_type": 24,
"giant_enum": "NorwayAunn",
"name": "高丽野阿吽",
"subtitle": "帝国的狛犬守卫",
"desc": "临时图鉴文案。攻击后可选择逃脱或石化,升级后可生成双身并共享生命,在防守与承伤中保护附近英雄。",
"diag": "暂未设置",
"desc": "饰演<color=red>莱夫·埃里克松</color>的狛犬守卫。她本该留在博丽神社看家,却被堇子轻易地骗来了比赛现场,她至今仍认为自己只是认真履行守护职责,并没有被“超大型守护任务”这些字眼骗到。",
"diag": "天呐,比神社大一百倍……咳,我只是担心灵梦小姐一个人忙不过来!",
"english_name": "AUNN KOMANO",
"achieve_pre_id": 0
"achieve_pre_id": 224
},
{
"giant_type": 25,
"giant_enum": "NorwaySuika",
"name": "伊吹萃香",
"subtitle": "帝国的鬼族怪力",
"desc": "临时图鉴文案。移动后生成小萃香,通过附着叠层进入大萃香或巨大萃香形态,以投掷和从天而降改写正面战场。",
"diag": "暂未设置",
"desc": "饰演<color=red>高个子索克尔</color>的酒豪。她听说比赛现场很热闹有各种各样的宴会便立刻在灵梦身边“聚集”齐了5位选手组队参赛。自她加入比赛之后似乎每天到赛场报名的选手越来越多。",
"diag": "庆功宴!好耶!!啊,原来输了吗?那就更该喝一场了吧~",
"english_name": "SUIKA IBUKI",
"achieve_pre_id": 0
"achieve_pre_id": 225
},
{
"giant_type": 26,
@ -841,7 +841,7 @@
"civ_name": "维京",
"force_name": "博丽帝国",
"leader_name": "博丽灵梦",
"empire_desc": "",
"empire_desc": "从约克到哥本哈根,从奥斯陆到温彻斯特,征服北海的辽阔疆域,如同制霸幻想乡一般易如反掌。继承了征服巨浪的意志,灵梦将以巫女的直觉和征服者的铁腕,将汇聚而来的信仰、财富与怨念统统塞进奉纳箱中,让博丽之名在瓦尔哈拉永恒传唱。",
"leader_desc": "懒散的博丽巫女抽到了<color=blue>维京</color>帝国卡,原本她只是在神社里无精打采的喝茶,结果听到奖金金额后当场坐直了。她加入比赛的理由很纯粹:奖金,顺带退治可疑的对手。",
"lines": {
"StartChatBubble": [

View File

@ -158,36 +158,36 @@ Do not edit this file by hand; rerun `scripts/extract_dialogue_corpus.py` after
- GiantType: `21`
- Subtitle: 帝国的御札巫女
- TH1 setting: 临时图鉴文案。可向友方施加<color=green>博丽加护</color>抵消伤害,并以<color=red>退治</color>标记敌方英雄,通过连动攻击与御札效果获得额外收益
- Dialogue: 暂未设置
- TH1 setting: 饰演<color=red>克努特大帝</color>的博丽巫女。她原本只是为了奖金和可疑黑幕参加比赛,直到抵达赛场后,才发现本该留在神社看家的阿吽也站在队伍里
- Dialogue: 我不是让你看家吗?……堇子,你最好解释一下。
### 宇佐见堇子 (NorwaySumireko)
- GiantType: `22`
- Subtitle: 帝国的灵异使者
- TH1 setting: 临时图鉴文案。可在战场上发射不同地形的灵异珠,影响附近单位的移动、攻击、防御与承伤规则
- Dialogue: 暂未设置
- TH1 setting: 饰演<color=red>诺曼底的爱玛</color>的女高中生。为了追寻灵异现象,她告诉阿吽“比赛非常需要你这样出色的狛犬来守护,而且守护规模是博丽神社的一百倍”,成功获得了可靠的保镖
- Dialogue: 这个游戏非常可疑充斥着各种灵异现象……什么你说这个叫“bug”
### 茨木华扇 (NorwayKasen)
- GiantType: `23`
- Subtitle: 帝国的兽引仙人
- TH1 setting: 临时图鉴文案。可设置全场唯一的<color=green>兽引</color>作为战术支点,通过召回、减伤、回复与传送能力支援战线
- Dialogue: 暂未设置
- Subtitle: 帝国的独臂仙人
- TH1 setting: 饰演<color=red>沃尔夫斯坦</color>的独臂仙人。为了阻止巫女沉迷可疑的游戏,她连续数日亲自研究规则、检查阵容、推演棋盘。她坚称这只是必要的调查,绝不是因为再打一局就能赢
- Dialogue: 监督者当然要熟悉规则。容我再研究一局,应该就能确认问题所在了。
### 高丽野阿吽 (NorwayAunn)
- GiantType: `24`
- Subtitle: 帝国的狛犬守卫
- TH1 setting: 临时图鉴文案。攻击后可选择逃脱或石化,升级后可生成双身并共享生命,在防守与承伤中保护附近英雄
- Dialogue: 暂未设置
- TH1 setting: 饰演<color=red>莱夫·埃里克松</color>的狛犬守卫。她本该留在博丽神社看家,却被堇子轻易地骗来了比赛现场,她至今仍认为自己只是认真履行守护职责,并没有被“超大型守护任务”这些字眼骗到
- Dialogue: 天呐,比神社大一百倍……咳,我只是担心灵梦小姐一个人忙不过来!
### 伊吹萃香 (NorwaySuika)
- GiantType: `25`
- Subtitle: 帝国的鬼族怪力
- TH1 setting: 临时图鉴文案。移动后生成小萃香,通过附着叠层进入大萃香或巨大萃香形态,以投掷和从天而降改写正面战场
- Dialogue: 暂未设置
- TH1 setting: 饰演<color=red>高个子索克尔</color>的酒豪。她听说比赛现场很热闹有各种各样的宴会便立刻在灵梦身边“聚集”齐了5位选手组队参赛。自她加入比赛之后似乎每天到赛场报名的选手越来越多
- Dialogue: 庆功宴!好耶!!啊,原来输了吗?那就更该喝一场了吧~
### 圣白莲(木偶) (BritishByakuren)

View File

@ -17,6 +17,10 @@ Keep this skill updated whenever a new movement/combat visual path, death path,
Gameplay state changes happen first in logic/action code; visual objects are only a presentation of `MapData`.
Every ability design must explicitly account for visual changes. If a skill changes unit position, unit health, unit form/sprite, unit status icons, grid occupancy, fog/sight, city ownership/host display, or highlights, the implementation must include the matching renderer refresh or Fragment path in the same change.
Any path that moves a unit without using the standard `UnitMoveAction` Fragment path must cache the origin grid, target grid, related cities, and `UnitRenderer` before calling `MoveToLogic`/`SetUnitIdToGridId`, then refresh the moved unit position/state, both grids, related cities, and nearby highlights on `Main.MapData`. This applies to throw, push, pull, teleport, swap, portal, landing, forced displacement, and ally/enemy reposition skills.
Any path that can kill a unit must either:
- use an existing attack/death Fragment that already cached the `UnitRenderer` before logic settlement, or
@ -74,6 +78,8 @@ Read these files before editing movement/combat animation behavior:
Important consequence: movement-triggered skills run during the move action before the move Fragment is played. If those skills deal damage or kill units, they must handle visuals themselves or enqueue a Fragment/skill effect.
Important consequence: `UnitLogic.MoveToLogic` is a data mutation helper, not a complete presentation path. Normal movement gets its visual refresh from `UnitMoveAction`; skill code that calls `MoveToLogic` directly must provide its own visual refresh or dedicated Fragment. Do not assume `MoveToLogic` will move the sprite, refresh old/target grids, or update city/highlight presentation.
### UnitAttackAction
`UnitAttackAction.Execute`:
@ -187,6 +193,23 @@ Projectile helpers written for attack Fragments usually require `PresentationMan
If the movement-triggered damage should be delayed until after the move animation, enqueue a `FragmentSkillEffect` after `UnitMoveAction` queues the move Fragment, or introduce a dedicated movement scope/collector. Do not do immediate renderer refresh that contradicts a queued move animation unless the current UX explicitly accepts that timing.
## Skill-Driven Displacement
Use this section for skills that move units outside normal `UnitMoveAction`: throws, portals, swaps, forced landing, push/pull, knockback, ally reposition, or any direct `MoveToLogic`/`SetUnitIdToGridId` call.
Minimum safe visual sequence:
1. Before the data mutation, cache `originGrid = unit.Grid(map)`, `originCity = unit.City(map)`, `targetCity = targetGrid.City(map, out var city) ? city : null`, and `unitRenderer = unit.Renderer(map)`.
2. Apply the authoritative movement through the existing logic path.
3. Guard presentation with `map == Main.MapData`.
4. Refresh `unitRenderer.InstantUpdateUnitPos()`, `unitRenderer.InstantUpdateUnit(true)`, and `unitRenderer.SyncStatusWithUnitSkills()`.
5. Refresh `originGrid.Renderer(map)?.InstantUpdateGrid()` and `targetGrid.Renderer(map)?.InstantUpdateGrid()`.
6. Refresh `originCity?.SetCityRenderer(map)` and `targetCity?.SetCityRenderer(map)` when different.
7. Refresh highlights around both origin and target with `MapRenderer.Instance?.UpdateAroundHighlight(map, grid)`, after null-checking the origin grid.
8. If the movement also causes damage/death, follow the death rules above and cache the damage target renderer before settlement.
Do not put this refresh inside `MoveToLogic` globally unless you are intentionally changing every movement path. Ordinary move/attack Fragments own their own timing, and AI/scoring/replay simulation maps must not run presentation side effects.
## Renderer Consistency
`MapRenderer.ROUnitMap` must match live units in `MapData.UnitMap`.
@ -211,9 +234,12 @@ When a dead unit sprite remains on the map:
6. Verify city/grid/highlight refresh uses cached `GridData`/`CityData`.
7. Check visibility guards: in-sight deaths need explicit `Die`; out-of-sight deaths have a `SetUnitDataDie` fallback.
8. If the skill should fire a projectile, verify the code path does not depend on `PresentationManager.CurrentScope` unless it is actually inside an attack Fragment.
9. Run `dotnet build Unity/TH1.Hotfix.csproj --no-restore`.
10. Use Unity Editor validation for actual timing because dotnet build cannot validate Fragment order visually.
9. If a skill directly moved a unit, verify the moved unit sprite, old grid, new grid, city info, fog/sight, status icons, and nearby highlights refresh.
10. Run `dotnet build Unity/TH1.Hotfix.csproj --no-restore`.
11. Use Unity Editor validation for actual timing because dotnet build cannot validate Fragment order visually.
## Known Pitfall
Lingering sprites commonly happen when a new skill uses `DamageSettlement` directly and only plays VFX/damage numbers after settlement. If the target dies, the data layer is already correct but the renderer remains. This is especially easy to miss in `OnMove`/`OnAnyUnitMove`, splash, aura, pulse, ground impact, and delayed skill effects.
Another common miss is direct skill movement: the data layer moves the unit, but the old sprite remains visually on the origin grid or the target grid does not update. Treat any throw/teleport/swap/push implementation as both a logic change and a presentation change.

View File

@ -0,0 +1,138 @@
---
name: th1-geo-copywriting
description: TH1 project workflow for designing, auditing, expanding, or rewriting GeoData/GeoDesc geography entries and encyclopedia-style copy for empire地理信息, including per-civilization Geo quotas, big/small class distribution, Chinese description length targets, real-place naming style, and consistency with the first eight empires' GeoDataAssets corpus. Use when asked for GeoData, GeoDesc, 地理信息系统文案, 地貌描述, 帝国地理条目, or adding the missing 9-17 empire geography content.
---
# TH1 Geo Copywriting
Use this skill to design or review TH1 `GeoDataAssets` / `GeoDesc` content. This is a content-design skill, not a code-chain skill.
## Source Corpus
The standard is inferred from the first eight empires in:
- `Unity/Assets/BundleResources/DataAssets/GeoDataAssets.asset`
- `ExcelExport/Excel/*/GeoDesc.xlsx` when editing spreadsheet source is required
Detailed corpus statistics are in `references/geo-copy-stats.md`. Per-entry CSV statistics are in `references/geo-copy-stats.csv`.
To refresh statistics after GeoData changes:
```powershell
python .codex/skills/th1-geo-copywriting/scripts/analyze_geo_copy.py
```
## Content Contract
Each Geo entry is a civilization-specific encyclopedia item:
- `GeoBigClass`: one of `Building`, `Mountain`, `Plain`, `Forest`, `Water`.
- `GeoSmallClass`: a concrete subtype such as `River`, `Mine`, `Deciduous`, `Floodplain`.
- `CivEnum`: the empire whose historical/geographical flavor owns the entry.
- `GeoName`: a real place, historical facility, landscape, water body, route, mine, port, bridge, forest, plain, or region.
- `GeoDesc`: one compact Chinese sentence explaining location plus historical/ecological/economic/strategic meaning.
Do not write game mechanics, stats, jokes, UI instructions, or generic fantasy flavor in Geo descriptions.
## Quantity Baseline
There is no explicit project document with hard quotas; use these inferred minimums from the shipped eight empires:
| Big Class | Recommended Count | Rule |
|---|---:|---|
| `Building` | 180 | Fixed template: 9 building subtypes x 20 each. |
| `Mountain` | 40-50 | Must include `Hill` and `Mountain`; add `Volcano` only if geographically justified. |
| `Plain` | 75-100 | Must include plain/agricultural lowland equivalents; distribute among floodplain, grassland, wetland, desert, tundra, etc. by empire. |
| `Forest` | about 50 | Pick 2-3 forest/ecology subtypes that fit the empire. |
| `Water` | 65-100 | Must include meaningful rivers/sea or lake systems; add special types such as `Fjord` only when distinctive. |
Target total per new empire: about 440-480 entries.
## Building Template
For every new empire, strongly prefer 20 entries each for:
- `NavelBase`: naval bases, arsenals, shipyards, maritime forts.
- `Military`: forts, barracks, garrisons, frontier defense sites.
- `Windmill`: agricultural infrastructure, granaries, irrigation, mills, plantations, waterworks.
- `Market`: bazaars, trading quarters, markets, workshops, caravan nodes.
- `Sawmill`: timber sites, sacred groves, forest-processing yards, ship timber reserves.
- `Port`: commercial ports, river ports, ancient harbors, docks.
- `Mine`: mines, quarries, saltworks, stone sources, gem fields.
- `Forge`: mints, foundries, metal workshops, arsenals.
- `Bridge`: bridges, causeways, aqueduct crossings, strategic river crossings.
If a subtype is culturally awkward, adapt the concept while preserving gameplay category. For example, `Windmill` can be irrigation/granary/agricultural processing, not literally a windmill.
## Natural Geography Allocation
Allocate natural subtypes by empire landscape, not by a universal checklist.
Examples from existing empires:
- Egyptian: floodplain, desert, oasis, Nile river system, Red Sea/Mediterranean coast.
- French: agricultural plains, deciduous forests, rivers, coasts, limited volcanoes.
- Germany: river valleys, lake systems, mixed forests, alpine/central mountains.
- Indian: floodplains, wetland, mangrove, jungle, sacred rivers.
- Norway: taiga, tundra, permafrost, fjords, mountains.
- Britain: grassland, wetlands, deciduous forests, seas/lakes/rivers.
- Persian: desert, oasis, mountains, plateau rivers, inland lakes.
- Byzantine: Mediterranean/Bosphorus water geography, Anatolian/Thracian plains, mixed forests.
For new empires, first write a one-paragraph "landscape profile", then assign subtype counts.
## Description Length
Measured on 3688 existing Chinese descriptions, excluding punctuation/spaces:
- Min: 21 Chinese chars.
- Median: 34.
- Average: 33.9.
- P90: 38.
- Max: 47.
Write one sentence per entry. Target 30-40 Chinese characters; accept 25-47 when needed. Avoid multi-sentence descriptions unless there is a strong reason.
Observed length distribution:
- 26-30: 541 entries.
- 31-35: 2066 entries.
- 36-40: 884 entries.
- 41-45: 167 entries.
## Sentence Pattern
Use one of these compact structures:
- `位于[地点/区域]的[地貌/设施],以[特征/产物/历史功能]闻名。`
- `[文明/地区]重要的[设施/资源/水体],曾/长期[历史作用]。`
- `[地理位置/自然属性],为[城市/帝国/贸易/农业/海军]提供[价值]。`
- `[真实地点名]是[区域/时代]的[代表性事物],见证/象征/连接[历史主题]。`
Keep the second clause concrete. Good nouns: `舰队`, `商路`, `冲积平原`, `盐矿`, `造船厂`, `圣林`, `关隘`, `灌溉`, `河口`, `铸币`, `边境防御`.
## Tone And Research Standard
Geo copy should feel like compact historical geography:
- Prefer real-world proper nouns.
- Mention exact regions, nearby cities, rivers, mountains, historical states, dynasties, or trade routes when useful.
- Use culturally anchored details: agriculture, naval power, sacred geography, mining, textiles, metalwork, pilgrimage, caravan trade, irrigation.
- Avoid vague praise such as `非常重要`, `十分美丽`, `历史悠久` unless paired with a concrete function.
- Avoid repeating the same sentence frame more than a few times in a row.
When unsure about a place or term, verify it before using it. Do not invent fake historical sites unless the project explicitly asks for fantasy alternatives.
## Audit Checklist
For a proposed empire Geo set:
- Check all five big classes are represented.
- Check `Building` has the 9 expected subtypes, preferably 20 entries each.
- Check natural subtypes match the empire's geography.
- Check every `GeoName` is specific, not generic.
- Check every `GeoDesc` is one compact sentence, usually 30-40 Chinese characters.
- Check descriptions state location plus function/significance.
- Check no gameplay mechanics or UI text appears in `GeoDesc`.
- Check repeated names are intentional cross-class uses, not accidental duplicates.
- Check no modern-only site dominates an ancient/civilization-flavored empire unless it is a deliberate historical-continuity anchor.

View File

@ -0,0 +1,4 @@
interface:
display_name: "TH1 Geo Copywriting"
short_description: "Design TH1 GeoData geography copy and quotas"
default_prompt: "Use $th1-geo-copywriting to design or audit TH1 GeoData entries for an empire."

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,917 @@
{
"global": {
"count": 3688,
"min": 21,
"max": 47,
"avg": 33.9,
"p50": 34,
"p90": 38
},
"length_buckets": [
[
"21-25",
21
],
[
"26-30",
541
],
[
"31-35",
2066
],
[
"36-40",
884
],
[
"41-45",
167
],
[
"46-50",
9
]
],
"by_big": [
[
"Building",
{
"count": 1427,
"min": 23,
"max": 46,
"avg": 34.47,
"p50": 34,
"p90": 39
}
],
[
"Forest",
{
"count": 396,
"min": 24,
"max": 47,
"avg": 34.1,
"p50": 34,
"p90": 38
}
],
[
"Mountain",
{
"count": 391,
"min": 24,
"max": 47,
"avg": 33.82,
"p50": 34,
"p90": 38
}
],
[
"Plain",
{
"count": 733,
"min": 25,
"max": 47,
"avg": 33.53,
"p50": 33,
"p90": 38
}
],
[
"Water",
{
"count": 741,
"min": 21,
"max": 46,
"avg": 33.09,
"p50": 33,
"p90": 38
}
]
],
"by_civ": [
[
"Britain",
{
"count": 467,
"min": 24,
"max": 46,
"avg": 34.05,
"p50": 34,
"p90": 38
}
],
[
"Byzantine",
{
"count": 412,
"min": 26,
"max": 44,
"avg": 33.78,
"p50": 34,
"p90": 37
}
],
[
"Egyptian",
{
"count": 483,
"min": 21,
"max": 46,
"avg": 33.82,
"p50": 34,
"p90": 38
}
],
[
"French",
{
"count": 480,
"min": 24,
"max": 47,
"avg": 34.27,
"p50": 34,
"p90": 39
}
],
[
"Germany",
{
"count": 480,
"min": 24,
"max": 43,
"avg": 33.37,
"p50": 33,
"p90": 38
}
],
[
"Indian",
{
"count": 443,
"min": 27,
"max": 43,
"avg": 33.85,
"p50": 34,
"p90": 37
}
],
[
"Norway",
{
"count": 465,
"min": 24,
"max": 47,
"avg": 34.15,
"p50": 34,
"p90": 40
}
],
[
"Persian",
{
"count": 458,
"min": 25,
"max": 46,
"avg": 33.86,
"p50": 33,
"p90": 40
}
]
],
"by_small": [
[
"Building/Bridge",
{
"count": 151,
"min": 26,
"max": 42,
"avg": 35.18,
"p50": 35,
"p90": 39
}
],
[
"Building/Forge",
{
"count": 160,
"min": 29,
"max": 44,
"avg": 33.83,
"p50": 33,
"p90": 38
}
],
[
"Building/Market",
{
"count": 160,
"min": 28,
"max": 44,
"avg": 35.97,
"p50": 36,
"p90": 41
}
],
[
"Building/Military",
{
"count": 160,
"min": 29,
"max": 46,
"avg": 35.96,
"p50": 36,
"p90": 41
}
],
[
"Building/Mine",
{
"count": 160,
"min": 26,
"max": 46,
"avg": 34.39,
"p50": 34,
"p90": 40
}
],
[
"Building/NavelBase",
{
"count": 160,
"min": 28,
"max": 42,
"avg": 34.19,
"p50": 34,
"p90": 38
}
],
[
"Building/Port",
{
"count": 156,
"min": 27,
"max": 43,
"avg": 33.72,
"p50": 33,
"p90": 38
}
],
[
"Building/Sawmill",
{
"count": 160,
"min": 23,
"max": 42,
"avg": 33.12,
"p50": 33,
"p90": 38
}
],
[
"Building/Windmill",
{
"count": 160,
"min": 26,
"max": 44,
"avg": 33.91,
"p50": 34,
"p90": 38
}
],
[
"Forest/Deciduous",
{
"count": 158,
"min": 28,
"max": 45,
"avg": 33.99,
"p50": 34,
"p90": 37
}
],
[
"Forest/Evergreen",
{
"count": 20,
"min": 31,
"max": 41,
"avg": 34.0,
"p50": 34,
"p90": 36
}
],
[
"Forest/Jungle",
{
"count": 20,
"min": 32,
"max": 39,
"avg": 35.05,
"p50": 35,
"p90": 38
}
],
[
"Forest/Mangrove",
{
"count": 16,
"min": 29,
"max": 43,
"avg": 35.88,
"p50": 35,
"p90": 43
}
],
[
"Forest/Oasis",
{
"count": 69,
"min": 27,
"max": 40,
"avg": 32.61,
"p50": 32,
"p90": 38
}
],
[
"Forest/Taiga",
{
"count": 113,
"min": 24,
"max": 47,
"avg": 34.74,
"p50": 35,
"p90": 39
}
],
[
"Mountain/Hill",
{
"count": 182,
"min": 25,
"max": 40,
"avg": 32.27,
"p50": 33,
"p90": 35
}
],
[
"Mountain/Mountain",
{
"count": 196,
"min": 24,
"max": 47,
"avg": 34.98,
"p50": 35,
"p90": 39
}
],
[
"Mountain/Volcano",
{
"count": 13,
"min": 33,
"max": 44,
"avg": 38.0,
"p50": 38,
"p90": 44
}
],
[
"Plain/Desert",
{
"count": 53,
"min": 29,
"max": 38,
"avg": 32.13,
"p50": 32,
"p90": 34
}
],
[
"Plain/Floodplain",
{
"count": 185,
"min": 27,
"max": 47,
"avg": 34.65,
"p50": 34,
"p90": 41
}
],
[
"Plain/Grassland",
{
"count": 70,
"min": 28,
"max": 42,
"avg": 32.99,
"p50": 33,
"p90": 39
}
],
[
"Plain/Permafrost",
{
"count": 35,
"min": 25,
"max": 36,
"avg": 31.09,
"p50": 32,
"p90": 35
}
],
[
"Plain/Plain",
{
"count": 315,
"min": 26,
"max": 41,
"avg": 33.19,
"p50": 33,
"p90": 37
}
],
[
"Plain/Tundra",
{
"count": 25,
"min": 29,
"max": 42,
"avg": 34.56,
"p50": 33,
"p90": 41
}
],
[
"Plain/Wetland",
{
"count": 50,
"min": 29,
"max": 41,
"avg": 34.92,
"p50": 35,
"p90": 39
}
],
[
"Water/Fjord",
{
"count": 34,
"min": 28,
"max": 42,
"avg": 35.26,
"p50": 34,
"p90": 41
}
],
[
"Water/Lake",
{
"count": 100,
"min": 27,
"max": 46,
"avg": 35.63,
"p50": 35,
"p90": 43
}
],
[
"Water/Ocean",
{
"count": 61,
"min": 21,
"max": 39,
"avg": 31.85,
"p50": 33,
"p90": 36
}
],
[
"Water/River",
{
"count": 305,
"min": 24,
"max": 41,
"avg": 32.53,
"p50": 32,
"p90": 37
}
],
[
"Water/Sea",
{
"count": 241,
"min": 24,
"max": 41,
"avg": 32.73,
"p50": 33,
"p90": 37
}
]
],
"leading_clauses": [
[
"普罗旺斯地区的石灰岩山脊",
3
],
[
"印度第二大咸水泻湖",
2
],
[
"被誉为“南方的恒河”",
2
],
[
"被誉为南印度的恒河",
2
],
[
"位于开罗东部的石灰岩高地",
2
],
[
"耸立在帝王谷上方的天然金字塔形山峰",
2
],
[
"位于尼罗河三角洲东部",
2
],
[
"法尤姆西南部的自然洼地",
2
],
[
"尼罗河三角洲北部的咸水泻湖",
2
],
[
"位于红海沿岸的季节性河流谷地",
2
],
[
"东部沙漠中的重要干河谷",
2
],
[
"陶努斯山脉的主峰",
2
],
[
"图林根北部的小型山脉",
2
],
[
"位于莱茵河与内卡河之间的丘陵平原",
2
],
[
"汉堡东南部的易北河冲积平原",
2
],
[
"梅克伦堡湖区的重要水体",
2
],
[
"位于小亚细亚西岸",
2
],
[
"位于布列塔尼半岛顶端的战略要地",
2
],
[
"西欧最大的河口湾",
2
],
[
"被称为“英格兰的脊梁”",
2
],
[
"约克郡的主要水系",
2
],
[
"苏格兰高地的短河",
2
],
[
"麦哲伦命名“和平之海”",
1
],
[
"哥伦布横渡之地",
1
],
[
"季风驱动的古老商路",
1
],
[
"以南极绕极流为界",
1
],
[
"曾是寻找西北航道的终点",
1
],
[
"中生代的古海洋",
1
],
[
"位于马图拉附近的砂岩丘陵",
1
],
[
"迈索尔市郊的一座孤立花岗岩山丘",
1
],
[
"孟买南部向海延伸的玄武岩海角",
1
],
[
"新德里的一处岩石高地",
1
],
[
"班加罗尔北部的一群巨大的整块花岗岩丘陵",
1
],
[
"浦那市区南部的一座圆顶形小山",
1
],
[
"斯利那加达尔湖畔的一座古老火山岩山丘",
1
],
[
"布巴内斯瓦尔附近的红土质丘陵",
1
],
[
"瓦多达拉东部的一座突兀的火山岩孤丘",
1
],
[
"博帕尔的一座重要丘陵",
1
],
[
"位于斯利那加的一座孤立山丘",
1
],
[
"位于孟买市区的黑色玄武岩柱状山体",
1
],
[
"达亚河畔的一座古老丘陵",
1
],
[
"钦奈南部的一座独立小丘",
1
],
[
"斋浦尔东郊的一处狭窄山谷丘陵",
1
],
[
"浦那市区内的最高点",
1
],
[
"海得拉巴郊区的一座圆顶状独石山丘",
1
],
[
"博帕尔著名的七座山丘之一",
1
],
[
"位于马尔瓦高原的温迪亚山脉支脉",
1
],
[
"那格浦尔西北部的一座平缓丘陵",
1
],
[
"印度最古老的褶皱山系",
1
],
[
"平行于印度西海岸绵延的断崖山脉",
1
],
[
"横贯印度中部的古老砂岩山脉",
1
],
[
"位于温迪亚山脉以南的一系列平行山脊",
1
],
[
"沿孟加拉湾沿岸断续分布的古老山地",
1
],
[
"东西高止山脉交汇处的“蓝山”区域",
1
],
[
"小喜马拉雅山脉中最大的分支",
1
],
[
"喜马拉雅山系最南端的外缘山麓",
1
],
[
"西高止山脉南段的崎岖高地",
1
],
[
"位于喜马拉雅山西北部的崎岖山脉",
1
],
[
"温迪亚山脉的东部延伸段",
1
],
[
"连接萨特普拉与温迪亚山脉的东部丘陵",
1
],
[
"位于恒河平原边缘的古老火山岩山脉",
1
],
[
"坐落于奥里萨邦的高地丘陵",
1
],
[
"东高止山脉中平行的断续山脊",
1
],
[
"西高止山脉向东延伸的支脉",
1
],
[
"位于泰米尔纳德邦北部的一组独立高地",
1
],
[
"古吉拉特邦的一座古老死火山山脉",
1
],
[
"位于马哈拉施特拉邦的一条重要山脊",
1
],
[
"喜马拉雅山脉外围最年轻的褶皱山系",
1
],
[
"东高止山脉中段的断块山地",
1
],
[
"萨特普拉山脉南侧的玄武岩支脉",
1
],
[
"德干高原西部的一系列低山丘陵",
1
],
[
"中央邦南部独特的砂岩山地",
1
],
[
"西高止山脉向东突出的山系",
1
],
[
"位于迈索尔平原上的孤立花岗岩残丘",
1
],
[
"凯厄瓦尔半岛上的古老火山遗迹",
1
],
[
"恒河平原东缘的火山岩山地",
1
],
[
"东高止山脉南段著名的避暑胜地",
1
],
[
"环绕古摩揭陀国都城的五座岩石山丘",
1
],
[
"位于恒河与亚穆纳河之间的狭长冲积地带",
1
],
[
"印度南部最著名的水稻产区",
1
],
[
"恒河三角洲西部的低洼平原",
1
],
[
"覆盖着富含矿物质的黑色火山土",
1
],
[
"恒河中游的广阔冲积地",
1
],
[
"萨拉尤河滋养的平坦耕地",
1
],
[
"东部沿海重要的扇形冲积平原",
1
],
[
"由多条河流冲积而成的肥沃低地",
1
],
[
"喜马拉雅山脉环抱中的宽阔盆地",
1
],
[
"德干高原东缘的肥沃河谷地带",
1
],
[
"位于斯利那加山谷边缘的古老湖泊沉积台地",
1
],
[
"位于亚穆纳河支流流域",
1
],
[
"位于西孟加拉邦的低洼平原",
1
],
[
"横贯印度中部的狭长河谷平原",
1
],
[
"恒河南岸的重要支流河谷",
1
],
[
"德干高原腹地的重要农业盆地",
1
],
[
"泰米尔纳德邦南部的核心农业区",
1
],
[
"古吉拉特邦中部的关键农业区",
1
],
[
"奥里萨邦沿海极其肥沃的地带",
1
],
[
"位于印度东南部的冲积平原",
1
],
[
"位于恒河与亚穆纳河汇流处的狭长肥沃地带",
1
],
[
"位于恒河上游东岸的肥沃冲积地",
1
]
]
}

View File

@ -0,0 +1,91 @@
# TH1 Geo Copywriting Corpus Statistics
Source: `Unity/Assets/BundleResources/DataAssets/GeoDataAssets.asset`.
## Global Length
- Entries: 3688
- Chinese character length excluding punctuation/spaces: min 21, p50 34, avg 33.9, p90 38, max 47.
- Description style: almost always one compact sentence with one or two clauses.
## Length Buckets
- 21-25: 21
- 26-30: 541
- 31-35: 2066
- 36-40: 884
- 41-45: 167
- 46-50: 9
## By Big Class
| Big | Count | Min | P50 | Avg | P90 | Max |
|---|---:|---:|---:|---:|---:|---:|
| Building | 1427 | 23 | 34 | 34.47 | 39 | 46 |
| Forest | 396 | 24 | 34 | 34.1 | 38 | 47 |
| Mountain | 391 | 24 | 34 | 33.82 | 38 | 47 |
| Plain | 733 | 25 | 33 | 33.53 | 38 | 47 |
| Water | 741 | 21 | 33 | 33.09 | 38 | 46 |
## By Civilization
| Civ | Count | Min | P50 | Avg | P90 | Max |
|---|---:|---:|---:|---:|---:|---:|
| Britain | 467 | 24 | 34 | 34.05 | 38 | 46 |
| Byzantine | 412 | 26 | 34 | 33.78 | 37 | 44 |
| Egyptian | 483 | 21 | 34 | 33.82 | 38 | 46 |
| French | 480 | 24 | 34 | 34.27 | 39 | 47 |
| Germany | 480 | 24 | 33 | 33.37 | 38 | 43 |
| Indian | 443 | 27 | 34 | 33.85 | 37 | 43 |
| Norway | 465 | 24 | 34 | 34.15 | 40 | 47 |
| Persian | 458 | 25 | 33 | 33.86 | 40 | 46 |
## Count By Civilization And Big Class
| Civ | Building | Mountain | Plain | Forest | Water | Total |
|---|---:|---:|---:|---:|---:|---:|
| Egyptian | 180 | 50 | 100 | 47 | 106 | 483 |
| French | 180 | 50 | 100 | 50 | 100 | 480 |
| Germany | 180 | 50 | 100 | 50 | 100 | 480 |
| Indian | 180 | 50 | 75 | 50 | 88 | 443 |
| Norway | 180 | 41 | 100 | 50 | 94 | 465 |
| Britain | 180 | 50 | 100 | 49 | 88 | 467 |
| Persian | 176 | 50 | 83 | 50 | 99 | 458 |
| Byzantine | 171 | 50 | 75 | 50 | 66 | 412 |
## Common Openers
- 普罗旺斯地区的石灰岩山脊: 3
- 印度第二大咸水泻湖: 2
- 被誉为“南方的恒河”: 2
- 被誉为南印度的恒河: 2
- 位于开罗东部的石灰岩高地: 2
- 耸立在帝王谷上方的天然金字塔形山峰: 2
- 位于尼罗河三角洲东部: 2
- 法尤姆西南部的自然洼地: 2
- 尼罗河三角洲北部的咸水泻湖: 2
- 位于红海沿岸的季节性河流谷地: 2
- 东部沙漠中的重要干河谷: 2
- 陶努斯山脉的主峰: 2
- 图林根北部的小型山脉: 2
- 位于莱茵河与内卡河之间的丘陵平原: 2
- 汉堡东南部的易北河冲积平原: 2
- 梅克伦堡湖区的重要水体: 2
- 位于小亚细亚西岸: 2
- 位于布列塔尼半岛顶端的战略要地: 2
- 西欧最大的河口湾: 2
- 被称为“英格兰的脊梁”: 2
- 约克郡的主要水系: 2
- 苏格兰高地的短河: 2
- 麦哲伦命名“和平之海”: 1
- 哥伦布横渡之地: 1
- 季风驱动的古老商路: 1
## Writing Conclusions
- Use one sentence per entry. Target 30-45 Chinese characters excluding punctuation; 25-50 is the observed normal range.
- Keep the sentence encyclopedic, not mechanical: location + historical/ecological/economic role.
- Avoid gameplay effect text. Geo copy names real places, facilities, landscapes, routes, ports, mines, forests, rivers, lakes, and symbolic regions.
- Prefer concrete nouns over generic flavor: named city/river/mountain/harbor + specific function.
- Building entries are the most templated; natural geography entries should vary by civilization landscape.

View File

@ -0,0 +1,340 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import json
import re
from collections import Counter, defaultdict
from pathlib import Path
BIG = {
0: "Building",
1: "Mountain",
2: "Plain",
3: "Forest",
4: "Water",
}
SMALL = {
0: "NavelBase",
1: "Military",
2: "Windmill",
3: "Market",
4: "Hill",
5: "Mountain",
6: "Volcano",
7: "Plain",
8: "Floodplain",
9: "Desert",
10: "Savanna",
11: "Sawmill",
12: "Mangrove",
13: "Deciduous",
14: "Jungle",
15: "Ocean",
16: "Sea",
17: "River",
18: "Lake",
19: "Port",
20: "Mine",
21: "Forge",
22: "Oasis",
23: "Marsh",
24: "Grassland",
25: "Evergreen",
26: "Taiga",
27: "Wetland",
28: "Permafrost",
29: "Tundra",
30: "Fjord",
31: "Bridge",
32: "Island",
33: "Peninsula",
}
CIV = {
1: "Egyptian",
2: "French",
3: "Germany",
4: "Indian",
5: "Norway",
6: "Britain",
7: "Persian",
8: "Byzantine",
9: "Sumerian",
10: "Mayan",
11: "Malian",
12: "Greek",
13: "Khmer",
14: "Aztec",
15: "Incan",
16: "Mongolian",
17: "Arabian",
}
PUNCT = set(",。、“”《》:;()—·!?,.!?;:()[]-")
def decode_unity_string(value: str) -> str:
value = value.strip()
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
if "\\" in value:
return value.encode("utf-8").decode("unicode_escape")
return value
def parse_geo_asset(path: Path) -> list[dict]:
items: list[dict] = []
current: dict | None = None
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.rstrip("\n")
if re.match(r"^ SmallClassInfoList:", line):
break
m = re.match(r"^ - Id: (\d+)", line)
if m:
if current:
items.append(current)
current = {
"id": int(m.group(1)),
"big": None,
"small": None,
"civ": None,
"name": "",
"desc": "",
}
continue
if not current:
continue
for key, pattern in (
("big", r"^ GeoBigClass: (\d+)"),
("small", r"^ GeoSmallClass: (\d+)"),
("civ", r"^ CivEnum: (\d+)"),
):
m = re.match(pattern, line)
if m:
current[key] = int(m.group(1))
break
else:
m = re.match(r"^ GeoName: (.*)$", line)
if m:
current["name"] = decode_unity_string(m.group(1))
continue
m = re.match(r"^ GeoDesc: (.*)$", line)
if m:
current["desc"] = decode_unity_string(m.group(1))
continue
if current:
items.append(current)
return items
def zh_len(text: str) -> int:
return sum(1 for ch in text if not ch.isspace() and ch not in PUNCT)
def char_len(text: str) -> int:
return sum(1 for ch in text if not ch.isspace())
def sentence_count(text: str) -> int:
pieces = [x for x in re.split(r"[。!?!?]+", text) if x.strip()]
return len(pieces)
def leading_clause(text: str) -> str:
text = text.strip()
for sep in ("", "", "", ""):
if sep in text:
return text.split(sep, 1)[0]
return text[:20]
def bucket(length: int) -> str:
if length <= 20:
return "00-20"
if length <= 25:
return "21-25"
if length <= 30:
return "26-30"
if length <= 35:
return "31-35"
if length <= 40:
return "36-40"
if length <= 45:
return "41-45"
if length <= 50:
return "46-50"
if length <= 60:
return "51-60"
return "60+"
def stats(values: list[int]) -> dict:
values = sorted(values)
if not values:
return {"count": 0, "min": 0, "max": 0, "avg": 0, "p50": 0, "p90": 0}
return {
"count": len(values),
"min": values[0],
"max": values[-1],
"avg": round(sum(values) / len(values), 2),
"p50": values[len(values) // 2],
"p90": values[int(len(values) * 0.9)],
}
def write_csv(items: list[dict], out_path: Path) -> None:
with out_path.open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(
f,
fieldnames=[
"id",
"civ",
"big",
"small",
"name",
"desc",
"zh_len",
"char_len",
"sentence_count",
"leading_clause",
],
)
writer.writeheader()
for item in items:
writer.writerow(
{
"id": item["id"],
"civ": CIV.get(item["civ"], str(item["civ"])),
"big": BIG.get(item["big"], str(item["big"])),
"small": SMALL.get(item["small"], str(item["small"])),
"name": item["name"],
"desc": item["desc"],
"zh_len": zh_len(item["desc"]),
"char_len": char_len(item["desc"]),
"sentence_count": sentence_count(item["desc"]),
"leading_clause": leading_clause(item["desc"]),
}
)
def group_stats(items: list[dict], key_fn) -> list[tuple[str, dict]]:
grouped: dict[str, list[int]] = defaultdict(list)
for item in items:
grouped[key_fn(item)].append(zh_len(item["desc"]))
return sorted((key, stats(values)) for key, values in grouped.items())
def count_table(items: list[dict], key_fn) -> list[tuple[str, int]]:
counts = Counter(key_fn(item) for item in items)
return sorted(counts.items())
def render_markdown(items: list[dict], summary: dict) -> str:
lines: list[str] = []
lines.append("# TH1 Geo Copywriting Corpus Statistics")
lines.append("")
lines.append("Source: `Unity/Assets/BundleResources/DataAssets/GeoDataAssets.asset`.")
lines.append("")
lines.append("## Global Length")
lines.append("")
g = summary["global"]
lines.append(f"- Entries: {g['count']}")
lines.append(f"- Chinese character length excluding punctuation/spaces: min {g['min']}, p50 {g['p50']}, avg {g['avg']}, p90 {g['p90']}, max {g['max']}.")
lines.append("- Description style: almost always one compact sentence with one or two clauses.")
lines.append("")
lines.append("## Length Buckets")
lines.append("")
for key, value in summary["length_buckets"]:
lines.append(f"- {key}: {value}")
lines.append("")
lines.append("## By Big Class")
lines.append("")
lines.append("| Big | Count | Min | P50 | Avg | P90 | Max |")
lines.append("|---|---:|---:|---:|---:|---:|---:|")
for key, s in summary["by_big"]:
lines.append(f"| {key} | {s['count']} | {s['min']} | {s['p50']} | {s['avg']} | {s['p90']} | {s['max']} |")
lines.append("")
lines.append("## By Civilization")
lines.append("")
lines.append("| Civ | Count | Min | P50 | Avg | P90 | Max |")
lines.append("|---|---:|---:|---:|---:|---:|---:|")
for key, s in summary["by_civ"]:
lines.append(f"| {key} | {s['count']} | {s['min']} | {s['p50']} | {s['avg']} | {s['p90']} | {s['max']} |")
lines.append("")
lines.append("## Count By Civilization And Big Class")
lines.append("")
lines.append("| Civ | Building | Mountain | Plain | Forest | Water | Total |")
lines.append("|---|---:|---:|---:|---:|---:|---:|")
for civ_id in range(1, 9):
civ_name = CIV[civ_id]
row = []
total = 0
for big_id in range(5):
count = sum(1 for item in items if item["civ"] == civ_id and item["big"] == big_id)
row.append(count)
total += count
lines.append(f"| {civ_name} | {row[0]} | {row[1]} | {row[2]} | {row[3]} | {row[4]} | {total} |")
lines.append("")
lines.append("## Common Openers")
lines.append("")
for clause, count in summary["leading_clauses"][:25]:
lines.append(f"- {clause}: {count}")
lines.append("")
lines.append("## Writing Conclusions")
lines.append("")
lines.append("- Use one sentence per entry. Target 30-45 Chinese characters excluding punctuation; 25-50 is the observed normal range.")
lines.append("- Keep the sentence encyclopedic, not mechanical: location + historical/ecological/economic role.")
lines.append("- Avoid gameplay effect text. Geo copy names real places, facilities, landscapes, routes, ports, mines, forests, rivers, lakes, and symbolic regions.")
lines.append("- Prefer concrete nouns over generic flavor: named city/river/mountain/harbor + specific function.")
lines.append("- Building entries are the most templated; natural geography entries should vary by civilization landscape.")
lines.append("")
return "\n".join(lines) + "\n"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--asset", default="Unity/Assets/BundleResources/DataAssets/GeoDataAssets.asset")
parser.add_argument("--out-dir", default=".codex/skills/th1-geo-copywriting/references")
args = parser.parse_args()
asset = Path(args.asset)
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
items = parse_geo_asset(asset)
enriched = []
for item in items:
enriched.append(
{
**item,
"zh_len": zh_len(item["desc"]),
"char_len": char_len(item["desc"]),
"sentence_count": sentence_count(item["desc"]),
"leading_clause": leading_clause(item["desc"]),
}
)
summary = {
"global": stats([item["zh_len"] for item in enriched]),
"length_buckets": count_table(enriched, lambda item: bucket(item["zh_len"])),
"by_big": group_stats(enriched, lambda item: BIG.get(item["big"], str(item["big"]))),
"by_civ": group_stats(enriched, lambda item: CIV.get(item["civ"], str(item["civ"]))),
"by_small": group_stats(enriched, lambda item: f"{BIG.get(item['big'])}/{SMALL.get(item['small'])}"),
"leading_clauses": Counter(item["leading_clause"] for item in enriched).most_common(100),
}
write_csv(enriched, out_dir / "geo-copy-stats.csv")
(out_dir / "geo-copy-stats.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
(out_dir / "geo-copy-stats.md").write_text(render_markdown(enriched, summary), encoding="utf-8-sig")
print(f"Parsed {len(items)} Geo entries")
print(f"Wrote {out_dir / 'geo-copy-stats.md'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -9,7 +9,9 @@ description: TH1 project Git workflow for checkpointing, commit splitting, verif
Treat commits as small, verified checkpoints grouped by behavioral risk, not by elapsed calendar time. Prefer a few coherent commits over one large mixed commit.
Do not auto-commit without showing the change groups and verification result unless the user explicitly requested unattended commit creation.
The TH1 owner has explicitly requested Codex-driven unattended checkpoint creation. For TH1 repository work, committing is part of the default definition of done: when Codex reaches a working, verified change group, it must proactively stage and create a commit before ending the turn.
Do not leave commit-worthy work uncommitted merely because the user did not repeat the commit request in the current message. Skip or delay the commit only when there is a concrete blocker: unresolved build/check failure, ambiguous unrelated user changes in the same files, protected build artifacts, missing required confirmation for high-risk changes, or the user explicitly asks not to commit.
## First Moves
@ -51,13 +53,14 @@ Split commits when runtime logic, generated/export outputs, docs, and tool chang
## Guardrails
Warn before staging or committing these paths unless the user explicitly asked for export/import workflow changes:
Treat these paths as advisory in commit checks. Warn so Codex reviews whether the change is intentional, but do not block normal user-driven TH1 content iteration because multilingual/export assets frequently change together with gameplay/config edits:
- `Unity/Assets/Resources/Export/*`
- `Unity/Assets/BundleResources/Export/*`
- `Tools/Multilingual.xlsx`
- `Tools/MultilingualTxt.txt`
Warn before committing local build or packaging artifacts:
Block or strongly reject local build or packaging artifacts:
- `Unity/Assets/StreamingAssets/HybridCLR/*`
- `Unity/Assets/StreamingAssets/Bundles/*`
@ -89,7 +92,17 @@ Report skipped verification and why.
## Codex Commit Protocol
When asked to checkpoint or commit:
For normal TH1 Codex work:
1. Before finishing, run `Tools/GitCheckpoint.ps1` if present.
2. Split work into coherent commit groups by behavioral boundary.
3. Stage only the files in the current group.
4. Run the narrowest required verification for that group.
5. Run `Tools/CommitTH1.ps1 -Message "<message>"`; use `-SkipBuild` only when the group has no buildable runtime/editor impact or a more specific verification was already run.
6. Repeat until all safe, commit-worthy groups are committed.
7. Report any remaining uncommitted files and the reason they were intentionally left out.
When the user specifically asks to checkpoint or commit:
1. Run `Tools/GitCheckpoint.ps1` if present.
2. Propose commit groups with exact file lists or path summaries.

View File

@ -1,6 +1,6 @@
---
name: th1-hero-implementation
description: TH1 project workflow for turning hero design documents into Unity implementation across UnitAction, UnitAttack, UnitAttackGround, UnitAttackAlly, SkillBase hooks, HeroTask, UnitTypeDataAssets, and hero-level progression. Use when Codex designs, reviews, implements, audits, or explains TH1 heroes, hero base stat standards, hero class/chess-role stats, hero skills, hero active actions, hero upgrade tasks, faction hero kits, or the existing first four factions' 20 hero implementation patterns.
description: TH1 project workflow and mandatory spec-confirmation gate for turning hero design documents into Unity implementation across UnitAction, UnitAttack, UnitAttackGround, UnitAttackAlly, SkillBase hooks, HeroTask, UnitTypeDataAssets, and hero-level progression. Use when Codex designs, reviews, implements, audits, fixes, or explains TH1 heroes, hero base stat standards, hero class/chess-role stats, hero skills, hero active actions, hero upgrade tasks, faction hero kits, hero status display, hero generated units, hero multi-body/link mechanics, or the existing first four factions' 20 hero implementation patterns.
---
# TH1 Hero Implementation
@ -28,6 +28,64 @@ Read these before changing hero behavior:
For Excel/config-backed keys, inspect the real rows before coding. Do not infer from names or similar heroes.
## Mandatory Hero Spec Gate
Before changing hero code, hero data, hero UI/status display, hero generated units, hero actions, or hero task logic, produce a concrete hero contract and use it as the implementation source of truth.
Do not write code or edit DataAssets while any item below is unknown, inferred, or contradicted. Stop and ask the user to confirm the missing design instead.
Required contract:
| Required section | Minimum content |
| --- | --- |
| Lv.1-Lv.4 effect matrix | Stats, skills, enabled actions, upgrade tasks, generated/linked units, board-visible statuses, and level-specific restrictions for every level. |
| Skill source map | For every skill/status/action: `SkillType`, `UnitActionType` if any, owning `UnitTypeDataAssets` row, `SkillDataAssets` display fields, icon, `ShowOnUnitMono`, `SkillPriority`, and whether it is a real unit skill or a computed/dynamic status. |
| State carrier map | For every persistent or temporary state, identify the exact carrier object (`UnitData`, `GridData`, `CityData`, `PlayerData`, action params, renderer-only view), owner/origin id, beneficiary, lifetime, and save/load behavior. |
| Program surface map | Map each design rule to exactly one primary surface: action entrypoint, `SkillBase` hook, UnitType row, HeroTask row, renderer/status display, AI scoring, animation fragment, or validation script. |
| Trigger and lifecycle table | For each rule, list all trigger paths that must keep it true: create, train, upgrade, transform, turn start/end, move, attack, ally attack, ground attack, any action completion, damage, healing, death, save/load, replay/spectator, and multiplayer sync when applicable. |
| Invariants | State hard rules that must always hold, such as one owner per generated unit, shared health equality, max one marker per hero, status is bool not stack, or Lv4 removes a prior restriction. |
| Non-goals and exclusions | State what must not happen: wrong levels, wrong targets, self/other-hero exclusions, no duplicate UnitMono icons, no UI-only mutations, no generated/export edits unless explicitly requested. |
| Verification plan | List the build command, static guardrails, config extraction checks, and Unity Editor/manual scenarios needed. |
Confirmation rule:
- For a new hero, a redesign, a multi-level hero skill, a linked-unit/multi-body mechanic, or any repeated defect, present the contract to the user before implementation and wait for confirmation.
- For a narrow bug fix, first restate the current contract slice, the observed behavior, and the intended behavior. If the slice cannot be proven from current code/data and user wording, stop and ask.
- "Fully confirmed" means the contract has no `unknown`, no implicit level boundary, no unexplained display source, and no unverified config key. Confidence or similarity to another hero is not enough.
- If the user says the agent is misunderstanding the hero, return to this gate. Do not continue patching from the old assumption.
- Treat user corrections as authoritative contract overrides, not as small wording edits. Re-summarize the corrected slice before continuing, especially when ownership, target type, action-point cost, display surface, or level boundary changes.
- During iterative design, maintain an explicit uncertainty list. If an implementation choice depends on an unanswered question, ask before coding instead of choosing from analogy.
## Complex Hero Dialogue Lessons
Use this section as a hard checklist after any Aunn-like or Kasen-like discussion with multiple bodies, markers, special tiles, or linked state.
- Separate `owner`, `actor`, `target`, and `beneficiary`. The unit that owns a marker, the unit that clicks an action, the unit standing on a tile, and the unit receiving the reward may all be different.
- Separate `presence`, `behavior`, and `presentation`. A visual marker such as `GridSpType` may only mean "draw this tile", while a `SkillBase` on the relevant runtime object carries owner, aura, turn hooks, and interaction rules.
- Prefer existing state carriers before adding fields to base data. If `UnitData`, `GridData`, `CityData`, or `PlayerData` already inherits `IdentifierBase` and can hold `Skills`, attach a focused `SkillBase` there and use `SkillBase.OriginId` for ownership. Add new base data fields only when no existing carrier represents the state and after explicit confirmation for serialization/compatibility impact.
- For special tiles with ownership, use a two-part model when possible: a lightweight tile/type flag for renderer and selection (`GridSpType`, resource, terrain, or equivalent) plus a skill on the grid for owner-specific logic. Do not infer owner from player, unit type, name, position, or first matching hero.
- For generated or placed objects, define cleanup and recovery for every path: owner death, owner upgrade/transform, marker removed by ally, marker removed by enemy, save/load, AI simulation copy, replay, and multiplayer execution.
- For action costs, record the exact action point consumed by each entrypoint. Do not carry over costs from similar actions, and do not assume "free" because a helper action is a reclaim/cleanup.
- For target entrypoints, keep the distinction exact: empty grid uses `UnitAttackGround`; allied unit target uses `UnitAttackAlly`; enemy unit target uses `UnitAttack`; explicit toolbar commands use `UnitAction` only when there is no natural board target.
- For dynamic readiness such as "after moving this turn", treat inventory and readiness as different states. One state may mean the hero owns an available charge; another may mean the current turn allows firing. Do not collapse them into a fake stack.
- For aura or placeholder display skills, decide whether the skill exists for behavior, explanation, board status, wiki/detail display, or renderer state. Do not duplicate icons on UnitMono unless the board needs that live status.
- When a design says "like an existing implementation", identify which layer is being borrowed. Example: "like Hakurei Rune" may mean grid icon rendering only, not no-owner data modeling, no-skill behavior, or action cost.
- Never let old implementation shape the new contract. Existing code can reveal current defects and useful hooks, but user-confirmed design replaces it.
## Anti-Regression Lessons
Use these failure patterns as hard checks for every complex hero:
- Do not treat "skill exists" as "skill state is correct". Validate owner/origin ids, linked units, generated marker identity, lifetime, and cleanup.
- Do not use `IsLevelSkill` for boolean state. If a state is on/off, store it as explicit state or use add/remove semantics so UI does not show fake stacks.
- Do not bypass level unlocks with runtime helper code. A helper may repair missing runtime state only after checking the hero level and the authoritative `UnitTypeDataAssets` row.
- Do not narrow a design phrase like "after any action" to one action type such as movement. List every action entrypoint before coding.
- Treat shared or linked state as an invariant. Cover damage, healing, creation, upgrade, transform, death, and save/load paths.
- Separate real unit skills from computed display statuses. Unit detail, grid info, UnitMono, aura, and marker displays may have different sources and must not duplicate icons.
- When a marker/special tile can be produced by more than one hero instance, every lookup must prove exact ownership through stored state such as `SkillBase.OriginId`. Same-player or same-`UnitFullType` lookup is not acceptable.
- When a reward is triggered by reclaiming, clearing, absorbing, or consuming a marker, prove who receives the reward. Do not default the reward to the hero owner if the acting unit or standing unit is the beneficiary.
- Do not edit Unity YAML/DataAssets with tools or encodings that can rewrite unrelated content. Prefer `apply_patch` for small text edits, inspect diffs immediately, and never leave encoding/BOM/truncation noise.
## Implementation Model
Map each design requirement to one existing program surface:
@ -43,6 +101,7 @@ Map each design requirement to one existing program surface:
| Ground/grid targeting | `UnitAttackGroundAction` + `IsCanAttackTargetGrid` / `OnBeInteractTarget` |
| Ally targeting, heal, buff, merge | `UnitAttackAllyAction` + ally-target Skill lifecycle |
| Movement trigger | `BeforeMove`, `OnMove`, `OnAnyUnitMove` |
| Forced movement, throw, teleport, swap, landing | Existing move logic plus `th1-combat-move-animation` visual refresh rules |
| Turn trigger | `OnTurnStart`, `OnTurnEnd` |
| Death/kill trigger | `OnDamageOther`, `AfterDamageOther`, `OnDamaged`, map/global death hooks |
| UI text/icon | DataAsset/localization path, not hardcoded game-facing strings |
@ -61,14 +120,25 @@ Do not choose `Origin` merely because a skill is permanent, level-unlocked, show
When changing a skill from `Origin` to `Normal`, update behavior gates that should respect banning to use `HasEffectiveSkill(...)`. Keep `GetSkill(...)` for structural state checks that intentionally survive ban, such as real form/body markers.
## New Unit Officer Design Check
When adding or changing any `UnitTypeDataAssets.UnitTypeInfo` row for a non-hero unit, explicitly decide whether the unit participates in the Feudal Fief / culture upgrade officer system before finalizing `Skills`.
- If the unit is an ordinary upgradeable land combat unit, add the correct prepare-officer skill (`SkillType.OFFICER`, or an intentional special variant such as `SkillType.JUNKEROFFICER`).
- If the unit is not supposed to culture-upgrade, record the design reason in the task notes or in the relevant guardrail script exemption instead of silently omitting the marker.
- Do not infer this from similar unit names. Check `ChessType`, `LandType`, cost, training/conversion source, and the design intent.
- Run `Tools/CheckCultureUpgradeUnitConfig.ps1` after changing ordinary unit rows. Run it with `-CheckExport` after the normal export workflow refreshes `Unity/Assets/BundleResources/Export/UnitTypeDataAssets.asset`.
## Unit Mono Status Display
`SkillInfo.ShowOnUnitMono` controls the small status icons above board units. It is not a marker for "important", "hero", "passive", "active", "unique", or "unlocked at this level". Default it to off for new hero and unit skills.
`SkillInfo.ShowOnUnitMono` controls the small status icons above board units. It is rare and should be treated as an exception, not a normal skill-display surface. It is not a marker for "important", "hero", "passive", "active", "unique", or "unlocked at this level". Default it to off for new hero and unit skills.
Turn it on only for runtime state that the player must read directly from the board: temporary or persistent buffs/debuffs applied by actions or skills, visible stack counters, statuses that will be consumed/expire, and enemy/ally markers that change the next interaction. Existing examples include fear, skill ban, hide state, damage/protection stacks, Kaguya permanent shield stacks, Reimu extermination/ofuda markers, and Suika mini-stack count.
Turn it on only for runtime state that the player must read directly from the board every moment: temporary or persistent buffs/debuffs applied by actions or skills, visible stack counters, statuses that will be consumed/expire, and enemy/ally markers that change the next interaction. Existing examples include fear, skill ban, hide state, damage/protection stacks, Kaguya permanent shield stacks, Reimu extermination/ofuda markers, and Suika mini-stack count.
Keep it off for skills that are part of a unit row or faction identity, even when they are powerful or permanent. This includes base passives, normal attack modifiers, board-target abilities, explicit UnitAction unlocks, movement rules from techs, summon-unit identity skills, aura definitions, structural form/body markers, and skills already visible through unit type, sprite, skill panel, action button, or wiki. Unit row skills in `UnitTypeDataAssets` should be presumed hidden unless they also create a dynamic board state that cannot be read elsewhere.
When a skill effect triggers, prefer `PlaySkillIcon`/skill icon VFX at the moment of effect as the common feedback surface. Do not use UnitMono icons as a substitute for trigger feedback, explanatory placeholder skills, or wiki/detail discoverability.
Before enabling `ShowOnUnitMono`, inspect current `SkillDataAssets.asset` true entries and the owning `UnitTypeDataAssets` rows. If the skill is mainly explanatory or always present while the unit exists, leave it hidden and use normal skill/detail UI for discoverability.
## Four Action Entrypoints
@ -132,6 +202,8 @@ When designing or auditing hero base stats, inspect `UnitTypeDataAssets.asset` a
Use this hero class standard as authoritative. The letter grade is the class priority; the number in parentheses is the target value. Opening attributes are Lv.1 targets. Max-level attributes are Lv.4 targets. Lv.2-Lv.3 should interpolate monotonically toward Lv.4 while preserving the class role.
For Knight / 马 hero attack specifically, the authoritative Lv.1-Lv.4 standard sequence is `3 / 3 / 3.5 / 4`. Do not derive it as linear interpolation from the Lv.1 and Lv.4 endpoints.
| Class | Overall | Opening strategy | Opening task |
| --- | --- | --- | --- |
| Bishop / 相 | Development `C`, exploration `S`, defense `C`, offense `C` | Main development without defense | Explore |
@ -152,21 +224,26 @@ Do not use first-four-faction historical values as the standard when they confli
## Hero Build Workflow
1. Restate the design as program surfaces: passive, normal attack modifier, ground target, ally target, explicit active button, movement/turn/death trigger, or upgrade task.
2. Inspect existing config/data rows for the intended unit, skill, action, resource, terrain, tech, and localization keys.
3. Reuse an existing Action entrypoint whenever possible. Only create a new `UnitActionType` when none of `UnitAttack`, `UnitAttackGround`, or `UnitAttackAlly` matches.
4. Implement the behavior in a focused `SkillBase` subclass or `UnitAction...` class. Keep gameplay mutations in action/skill execution, not UI, AI, or renderer code.
5. Add or update `UnitTypeDataAssets` hero level rows: stats, skills, and enabled actions.
6. Add or update `HeroDataAssets` task rows when the hero needs level-up requirements or task-triggered rewards.
7. Add UI-facing text through the existing data/localization path. Do not hardcode game-facing non-debug strings.
8. Check AI visibility: if a new active action or target mode should be AI-usable, update action generation/scoring or BT nodes.
9. Check multiplayer determinism: avoid `UnityEngine.Random`, wall-clock time, unordered iteration, and direct unsynchronized mutations.
10. Verify with `dotnet build Unity/TH1.Hotfix.csproj --no-restore` for runtime hotfix changes. Use Unity Editor validation for prefab UI, animation timing, Steam multiplayer, replay/spectator, and behavior tree scenarios.
1. Complete the Mandatory Hero Spec Gate. Do not implement until the contract is confirmed or the narrow bug-fix slice is fully proven.
2. Restate the design as program surfaces: passive, normal attack modifier, ground target, ally target, explicit active button, movement/turn/death trigger, generated unit, board-visible status, or upgrade task.
3. Inspect existing config/data rows for the intended unit, skill, action, resource, terrain, tech, icon, status display, generated unit, and localization keys.
4. Reuse an existing Action entrypoint whenever possible. Only create a new `UnitActionType` when none of `UnitAttack`, `UnitAttackGround`, or `UnitAttackAlly` matches.
5. Implement the behavior in a focused `SkillBase` subclass or `UnitAction...` class. Keep gameplay mutations in action/skill execution, not UI, AI, renderer, or network receiver code.
6. Add or update `UnitTypeDataAssets` hero level rows: stats, skills, and enabled actions. Re-check all four levels after any helper code that can add/remove skills at runtime.
7. Add or update `HeroDataAssets` task rows when the hero needs level-up requirements or task-triggered rewards.
8. Add UI-facing text/icons through the existing data/localization path. Do not hardcode game-facing non-debug strings.
9. Check AI visibility: if a new active action or target mode should be AI-usable, update action generation/scoring or BT nodes.
10. Check visual consequences for every ability: unit position, generated markers, unit sprite/form, health/status icons, grid occupancy, fog/sight, city info, VFX, death cleanup, duplicate UnitMono icons, and highlights. If the hero skill moves a unit outside `UnitMoveAction`, use `th1-combat-move-animation` and refresh the old grid, target grid, moved unit, related cities, and nearby highlights.
11. Check multiplayer determinism: avoid `UnityEngine.Random`, wall-clock time, unordered iteration, and direct unsynchronized mutations.
12. Add a focused guardrail script for repeated defects, multi-body/link mechanics, generated-unit ownership, level unlocks, or fragile UI/status behavior when a static check can catch the regression.
13. Verify with `dotnet build Unity/TH1.Hotfix.csproj --no-restore` for runtime hotfix changes. Use Unity Editor validation for prefab UI, animation timing, Steam multiplayer, replay/spectator, and behavior tree scenarios.
## Guardrails
- Do not edit generated config or export outputs directly unless the user explicitly asks for the documented export/import workflow.
- Do not modify obfuscation, compatibility, or MemoryPack-related code without repeated explicit user confirmation.
- Do not modify obfuscation, compatibility, or MemoryPack-related code without repeated explicit user confirmation. Exception: after the hero feature contract is approved, append-only skill additions are allowed without another confirmation: new `SkillType` ids, new `SkillBase` subclasses, generated MemoryPackable partials, and appended SkillBase union entries. Do not reuse, reorder, remove, or repurpose existing skill ids or union ids.
- Do not implement a hero request when the level matrix, trigger paths, display source, target rules, or config keys are unclear. Ask the user first.
- Do not bypass `CommonActionParams`, `CheckCan`, and `ActionLogicBase.CompleteExecute` for authoritative gameplay changes.
- Do not add hero behavior only in UI, AI, renderer, or network receiver code.
- Do not treat hero abilities as logic-only. Every state change must have an explicit presentation answer, especially forced movement, throw, teleport, swap, landing, direct damage, death, transform/form changes, and board-visible statuses.
- Keep visual effects inside the existing presentation/fragment collection pattern; attack-related visual changes must respect animation scope when modifying attack lifecycle skills.

View File

@ -1,4 +1,4 @@
interface:
display_name: "TH1 Hero Implementation"
short_description: "Turn hero designs into TH1 code structure"
default_prompt: "Use $th1-hero-implementation to implement a TH1 hero design from planning notes into code and data."
short_description: "Confirm hero specs before implementation"
default_prompt: "Use $th1-hero-implementation to confirm a TH1 hero spec contract before changing hero code or data."

View File

@ -20,6 +20,7 @@ Use this skill before changing `TechDataAssets.asset`, `TechDataAssets.cs`, or `
- `TechType` and `TechAtom` enum order is serialized data. Append new values only. Do not delete, reorder, or reuse numeric slots; mark obsolete entries as `NoUse...`/废弃占位 if needed.
- Keep generated/export files untouched unless the user explicitly asks for export/import workflow changes.
- For a new build/train/upgrade action, verify or add the matching `ActionDataAssets.asset` row and runtime action logic.
- For a tech that unlocks or creates a new non-hero unit, explicitly confirm the unit's officer/culture-upgrade design before finalizing `UnitTypeDataAssets.Skills`. Ordinary upgradeable land combat units need `SkillType.OFFICER` or an intentional prepare-officer variant; non-upgradeable units need a stated design reason.
- When replacing a tech, preserve its `CostLevel`, `TechTreeCircleViewType`, and intended father relationship unless the design explicitly changes the tree.
- When replacing a tech that other technologies depend on, also update the downstream technologies' `FatherTechList`. `FatherTechList` is an OR-list in the current runtime: a tech is learnable if the player has any listed father. If a faction removes base `Ramming` from `TechPool` and uses `HakureiRamming` instead, downstream shared techs such as `Aquatism` must list `HakureiRamming` as an alternate father, or that faction can never learn the shared downstream tech even though the tree visually keeps the slot.
- If a faction tech still needs the base action, include the original base TechAtom plus the new faction TechAtom. Do not replace the atom just for wording.
@ -63,7 +64,8 @@ Use existing tech names as the style baseline before proposing or editing names.
8. Update `PlayerInfo.TechPool` for learnable replacement, `TechStart` for true initial ownership, and `TechAtomList` only for up to 4 display atoms.
9. Implement any passive behavior in runtime code if no existing system consumes the new TechAtom.
10. Update AI scoring and unit/action mapping for replaced strategic techs.
11. Run the relevant build, usually `dotnet build Unity/TH1.Hotfix.csproj --no-restore`.
11. If unit rows changed, run `Tools/CheckCultureUpgradeUnitConfig.ps1`; after normal export refresh, run `Tools/CheckCultureUpgradeUnitConfig.ps1 -CheckExport`.
12. Run the relevant build, usually `dotnet build Unity/TH1.Hotfix.csproj --no-restore`.
## Common Checks

View File

@ -52,7 +52,8 @@ Do not reuse a generic placeholder name such as `BigGuy` or another faction's sh
3. Create a new `.meta` instead of reusing an old GUID unless intentionally replacing the old asset in place.
4. Use `scripts/prepare_unit_sprite.py` for deterministic canvas, alpha crop, composition, and `.meta` generation.
5. Update only `Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset` unless the user explicitly asks to modify export-flow outputs. `Unity/Assets/BundleResources/Export/*` is generated by the manual export process.
6. Verify the new PNG dimensions, nontransparent bbox, GUID references, and git diff.
6. If this sprite work also creates or changes a unit row, confirm the unit's officer/culture-upgrade design before finalizing `Skills`: ordinary upgradeable land combat units need `SkillType.OFFICER` or an intentional prepare-officer variant, while non-upgradeable units need an explicit design reason.
7. Verify the new PNG dimensions, nontransparent bbox, GUID references, and git diff. If unit rows changed, also run `Tools/CheckCultureUpgradeUnitConfig.ps1`.
## Script

View File

@ -75,6 +75,13 @@ Tools/InstallGraphifyHook.ps1
Tools/GraphifyPostCommit.ps1 -DryRun
```
## Codex Auto Commit
- The repository owner has requested proactive Codex commits. For TH1 work, a verified commit is part of the default finish criteria.
- Before ending a code, data, docs, resource, or tool change, Codex must inspect `git status`, group safe commit-worthy changes, run the relevant verification, stage only that group, and create a commit.
- Do not wait for a separate commit request when a coherent verified group is ready. Leave files uncommitted only for a concrete blocker such as failed verification, unrelated user changes mixed into the same files, protected build artifacts, missing high-risk confirmation, or an explicit user instruction not to commit.
- If any files remain uncommitted, Codex must state exactly which category remains and why.
## Verification
- For most Unity hotfix/runtime C# changes, run:

View File

@ -1,5 +1,5 @@
{
"nextId": 388,
"nextId": 389,
"bugs": [
{
"id": 2,
@ -3909,12 +3909,12 @@
"id": 382,
"title": "话说现在默认八人好像没有红魔馆了,被白玉替代了,雪糕有什么头猪吗",
"description": "",
"status": "open",
"status": "fixed",
"priority": "medium",
"module": "",
"longTerm": false,
"createdAt": 1781938406585,
"updatedAt": 1781938406585
"updatedAt": 1782369774838
},
{
"id": 383,
@ -3953,12 +3953,12 @@
"id": 386,
"title": "在房间里切阵营有时候切不到博丽帝国的bug",
"description": "",
"status": "open",
"status": "fixed",
"priority": "medium",
"module": "",
"longTerm": false,
"createdAt": 1782217693173,
"updatedAt": 1782217693173
"updatedAt": 1782369766242
},
{
"id": 387,
@ -3970,6 +3970,17 @@
"longTerm": false,
"createdAt": 1782311317232,
"updatedAt": 1782311317232
},
{
"id": 388,
"title": "学者转化的白板巨人主动下海不是巨人船,没有溅射",
"description": "",
"status": "open",
"priority": "medium",
"module": "",
"longTerm": false,
"createdAt": 1782453603577,
"updatedAt": 1782453603577
}
]
}

View File

@ -0,0 +1,140 @@
{
"schema": "th1-local-icon-draft-v1",
"scope": "hakurei-kasen",
"created_at": "2026-06-26T15:59:15+08:00",
"run_dir": "C:\\TH1\\TH1\\Design\\drafts\\skill-icons\\hakurei\\20260626-155915-kasen-icons",
"style": {
"size": "256x256",
"format": "PNG RGBA",
"foreground": "white only",
"background": "transparent",
"method": "programmatic Pillow geometry"
},
"objects": [
{
"id": "clear_beast_guide",
"zh_name": "清除兽引",
"mechanic": "敌方站在兽引上,消耗施法行动点清除该兽引。",
"variants": [
{
"variant": 1,
"filename": "clear_beast_guide_v1.png",
"path": "variants/clear_beast_guide_v1.png",
"direction": "断裂兽引印记 + 斜向切断"
},
{
"variant": 2,
"filename": "clear_beast_guide_v2.png",
"path": "variants/clear_beast_guide_v2.png",
"direction": "被劈开的信标桩"
},
{
"variant": 3,
"filename": "clear_beast_guide_v3.png",
"path": "variants/clear_beast_guide_v3.png",
"direction": "兽爪印被斜线抹除"
}
]
},
{
"id": "two_gates_pengzu",
"zh_name": "两门的彭祖",
"mechanic": "Lv2 展示:兽引周围友方回合开始回复生命并获得防御,回收者额外恢复。",
"variants": [
{
"variant": 1,
"filename": "two_gates_pengzu_v1.png",
"path": "variants/two_gates_pengzu_v1.png",
"direction": "双门 + 虎面负形"
},
{
"variant": 2,
"filename": "two_gates_pengzu_v2.png",
"path": "variants/two_gates_pengzu_v2.png",
"direction": "双门盾形"
},
{
"variant": 3,
"filename": "two_gates_pengzu_v3.png",
"path": "variants/two_gates_pengzu_v3.png",
"direction": "极简虎面与门叶"
}
]
},
{
"id": "pengzu_guard",
"zh_name": "彭祖守势",
"mechanic": "实际 Buff兽引周围 1 格友方防御 +1。",
"variants": [
{
"variant": 1,
"filename": "pengzu_guard_v1.png",
"path": "variants/pengzu_guard_v1.png",
"direction": "盾中双门"
},
{
"variant": 2,
"filename": "pengzu_guard_v2.png",
"path": "variants/pengzu_guard_v2.png",
"direction": "虎额压住防线"
},
{
"variant": 3,
"filename": "pengzu_guard_v3.png",
"path": "variants/pengzu_guard_v3.png",
"direction": "闭合双门守势"
}
]
},
{
"id": "dragon_roar",
"zh_name": "巨龙之啸",
"mechanic": "Lv3 展示:兽引周围友方攻击 +1回合开始获得狂暴。",
"variants": [
{
"variant": 1,
"filename": "dragon_roar_v1.png",
"path": "variants/dragon_roar_v1.png",
"direction": "龙首咆哮"
},
{
"variant": 2,
"filename": "dragon_roar_v2.png",
"path": "variants/dragon_roar_v2.png",
"direction": "龙口与三段啸声"
},
{
"variant": 3,
"filename": "dragon_roar_v3.png",
"path": "variants/dragon_roar_v3.png",
"direction": "盘龙啸口"
}
]
},
{
"id": "huangdi_attack",
"zh_name": "黄帝攻势",
"mechanic": "实际 Buff兽引周围 1 格友方攻击 +1。",
"variants": [
{
"variant": 1,
"filename": "huangdi_attack_v1.png",
"path": "variants/huangdi_attack_v1.png",
"direction": "龙爪与突进箭"
},
{
"variant": 2,
"filename": "huangdi_attack_v2.png",
"path": "variants/huangdi_attack_v2.png",
"direction": "龙角长矛"
},
{
"variant": 3,
"filename": "huangdi_attack_v3.png",
"path": "variants/huangdi_attack_v3.png",
"direction": "龙首冲击"
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -0,0 +1,15 @@
{"id": "clear_beast_guide_v1", "object_id": "clear_beast_guide", "zh_name": "清除兽引", "variant": 1, "direction": "断裂兽引印记 + 斜向切断", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 清除兽引 / 断裂兽引印记 + 斜向切断. Mechanic: 敌方站在兽引上,消耗施法行动点清除该兽引。", "final_path": "variants/clear_beast_guide_v1.png"}
{"id": "clear_beast_guide_v2", "object_id": "clear_beast_guide", "zh_name": "清除兽引", "variant": 2, "direction": "被劈开的信标桩", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 清除兽引 / 被劈开的信标桩. Mechanic: 敌方站在兽引上,消耗施法行动点清除该兽引。", "final_path": "variants/clear_beast_guide_v2.png"}
{"id": "clear_beast_guide_v3", "object_id": "clear_beast_guide", "zh_name": "清除兽引", "variant": 3, "direction": "兽爪印被斜线抹除", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 清除兽引 / 兽爪印被斜线抹除. Mechanic: 敌方站在兽引上,消耗施法行动点清除该兽引。", "final_path": "variants/clear_beast_guide_v3.png"}
{"id": "two_gates_pengzu_v1", "object_id": "two_gates_pengzu", "zh_name": "两门的彭祖", "variant": 1, "direction": "双门 + 虎面负形", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 两门的彭祖 / 双门 + 虎面负形. Mechanic: Lv2 展示:兽引周围友方回合开始回复生命并获得防御,回收者额外恢复。", "final_path": "variants/two_gates_pengzu_v1.png"}
{"id": "two_gates_pengzu_v2", "object_id": "two_gates_pengzu", "zh_name": "两门的彭祖", "variant": 2, "direction": "双门盾形", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 两门的彭祖 / 双门盾形. Mechanic: Lv2 展示:兽引周围友方回合开始回复生命并获得防御,回收者额外恢复。", "final_path": "variants/two_gates_pengzu_v2.png"}
{"id": "two_gates_pengzu_v3", "object_id": "two_gates_pengzu", "zh_name": "两门的彭祖", "variant": 3, "direction": "极简虎面与门叶", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 两门的彭祖 / 极简虎面与门叶. Mechanic: Lv2 展示:兽引周围友方回合开始回复生命并获得防御,回收者额外恢复。", "final_path": "variants/two_gates_pengzu_v3.png"}
{"id": "pengzu_guard_v1", "object_id": "pengzu_guard", "zh_name": "彭祖守势", "variant": 1, "direction": "盾中双门", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 彭祖守势 / 盾中双门. Mechanic: 实际 Buff兽引周围 1 格友方防御 +1。", "final_path": "variants/pengzu_guard_v1.png"}
{"id": "pengzu_guard_v2", "object_id": "pengzu_guard", "zh_name": "彭祖守势", "variant": 2, "direction": "虎额压住防线", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 彭祖守势 / 虎额压住防线. Mechanic: 实际 Buff兽引周围 1 格友方防御 +1。", "final_path": "variants/pengzu_guard_v2.png"}
{"id": "pengzu_guard_v3", "object_id": "pengzu_guard", "zh_name": "彭祖守势", "variant": 3, "direction": "闭合双门守势", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 彭祖守势 / 闭合双门守势. Mechanic: 实际 Buff兽引周围 1 格友方防御 +1。", "final_path": "variants/pengzu_guard_v3.png"}
{"id": "dragon_roar_v1", "object_id": "dragon_roar", "zh_name": "巨龙之啸", "variant": 1, "direction": "龙首咆哮", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 巨龙之啸 / 龙首咆哮. Mechanic: Lv3 展示:兽引周围友方攻击 +1回合开始获得狂暴。", "final_path": "variants/dragon_roar_v1.png"}
{"id": "dragon_roar_v2", "object_id": "dragon_roar", "zh_name": "巨龙之啸", "variant": 2, "direction": "龙口与三段啸声", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 巨龙之啸 / 龙口与三段啸声. Mechanic: Lv3 展示:兽引周围友方攻击 +1回合开始获得狂暴。", "final_path": "variants/dragon_roar_v2.png"}
{"id": "dragon_roar_v3", "object_id": "dragon_roar", "zh_name": "巨龙之啸", "variant": 3, "direction": "盘龙啸口", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 巨龙之啸 / 盘龙啸口. Mechanic: Lv3 展示:兽引周围友方攻击 +1回合开始获得狂暴。", "final_path": "variants/dragon_roar_v3.png"}
{"id": "huangdi_attack_v1", "object_id": "huangdi_attack", "zh_name": "黄帝攻势", "variant": 1, "direction": "龙爪与突进箭", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 黄帝攻势 / 龙爪与突进箭. Mechanic: 实际 Buff兽引周围 1 格友方攻击 +1。", "final_path": "variants/huangdi_attack_v1.png"}
{"id": "huangdi_attack_v2", "object_id": "huangdi_attack", "zh_name": "黄帝攻势", "variant": 2, "direction": "龙角长矛", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 黄帝攻势 / 龙角长矛. Mechanic: 实际 Buff兽引周围 1 格友方攻击 +1。", "final_path": "variants/huangdi_attack_v2.png"}
{"id": "huangdi_attack_v3", "object_id": "huangdi_attack", "zh_name": "黄帝攻势", "variant": 3, "direction": "龙首冲击", "prompt": "Programmatic TH1 skill icon draft: 256x256 transparent PNG, white-only flat silhouette, ultra-simple low-poly geometric glyph, 黄帝攻势 / 龙首冲击. Mechanic: 实际 Buff兽引周围 1 格友方攻击 +1。", "final_path": "variants/huangdi_attack_v3.png"}

View File

@ -0,0 +1,4 @@
{
"selections": {},
"feedback": {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,3 @@
# 本轮反馈意见
暂无。

View File

@ -0,0 +1,136 @@
{
"schema": "th1-skill-icon-draft-run-v1",
"run_id": "20260626-160741-kasen-icons-readable",
"scope": "hakurei",
"character": "\u8328\u6728\u534e\u6247",
"created_at": "2026-06-26T16:07:41+08:00",
"style": "256x256 transparent PNG, white-only foreground, medium-detail silhouette, no text/color/shadow/gradient",
"feedback_basis_zh": "\u4e0a\u4e00\u8f6e\u8fc7\u4e8e\u6781\u7b80\u3001\u7b26\u53f7\u592a\u62bd\u8c61\uff1b\u672c\u8f6e\u63d0\u9ad8\u526a\u5f71\u7ec6\u8282\u548c\u7b26\u5361\u610f\u8c61\u8bc6\u522b\u5ea6\u3002",
"objects": [
{
"id": "clear_beast_guide",
"name_zh": "\u6e05\u9664\u517d\u5f15",
"effect_zh": "\u79fb\u9664\u5df2\u8bbe\u7f6e\u7684\u517d\u5f15\u6807\u8bb0\u3002",
"variants": [
{
"id": "v1",
"index": 1,
"filename": "variants/clear_beast_guide_v1.png",
"description_zh": "\u517d\u5f15\u4fe1\u6807\u88ab\u65a9\u65ad\uff0c\u5e26\u722a\u5370\u548c\u5c01\u5370\u73af"
},
{
"id": "v2",
"index": 2,
"filename": "variants/clear_beast_guide_v2.png",
"description_zh": "\u517d\u5f15\u7b26\u6869\u88ab\u7834\u574f\u5e76\u4ece\u5730\u9762\u62d4\u8d77"
},
{
"id": "v3",
"index": 3,
"filename": "variants/clear_beast_guide_v3.png",
"description_zh": "\u517d\u722a\u5c01\u5370\u88ab\u53cc\u65a9\u6e05\u9664"
}
]
},
{
"id": "two_gates_pengzu",
"name_zh": "\u4e24\u95e8\u7684\u5f6d\u7956",
"effect_zh": "\u517d\u5f15\u76f8\u5173\u7684\u7075\u6108/\u7b49\u7ea7\u5c55\u793a\u6548\u679c\uff0c\u5f3a\u8c03\u5f6d\u7956\u4e0e\u53cc\u95e8\u7b26\u5361\u610f\u8c61\u3002",
"variants": [
{
"id": "v1",
"index": 1,
"filename": "variants/two_gates_pengzu_v1.png",
"description_zh": "\u53cc\u95e8\u524d\u7684\u864e\u9762\u5f6d\u7956"
},
{
"id": "v2",
"index": 2,
"filename": "variants/two_gates_pengzu_v2.png",
"description_zh": "\u5f6d\u7956\u7a7f\u8fc7\u53cc\u95e8\u7684\u517d\u5f15\u59ff\u6001"
},
{
"id": "v3",
"index": 3,
"filename": "variants/two_gates_pengzu_v3.png",
"description_zh": "\u95e8\u73af\u4e0e\u5f6d\u7956\u864e\u9762\u7684\u72b6\u6001\u7b26\u53f7"
}
]
},
{
"id": "pengzu_guard",
"name_zh": "\u5f6d\u7956\u5b88\u52bf",
"effect_zh": "\u517d\u5f15\u63d0\u4f9b\u9632\u5fa1\u5411\u589e\u76ca\u3002",
"variants": [
{
"id": "v1",
"index": 1,
"filename": "variants/pengzu_guard_v1.png",
"description_zh": "\u864e\u9762\u7f6e\u4e8e\u76fe\u4e2d"
},
{
"id": "v2",
"index": 2,
"filename": "variants/pengzu_guard_v2.png",
"description_zh": "\u53cc\u95e8\u95ed\u5408\uff0c\u864e\u76ee\u5b88\u62a4"
},
{
"id": "v3",
"index": 3,
"filename": "variants/pengzu_guard_v3.png",
"description_zh": "\u5f6d\u7956\u722a\u5370\u538b\u5728\u57ce\u5899\u76fe\u9762\u4e0a"
}
]
},
{
"id": "dragon_roar",
"name_zh": "\u5de8\u9f99\u4e4b\u5578",
"effect_zh": "\u517d\u5f15\u72c2\u5316\u7b49\u7ea7\u5c55\u793a/\u9f99\u5578\u610f\u8c61\uff0c\u5f3a\u8c03\u7206\u53d1\u4e0e\u5a01\u538b\u3002",
"variants": [
{
"id": "v1",
"index": 1,
"filename": "variants/dragon_roar_v1.png",
"description_zh": "\u4fa7\u9762\u9f99\u9996\u5f20\u53e3\u5486\u54ee"
},
{
"id": "v2",
"index": 2,
"filename": "variants/dragon_roar_v2.png",
"description_zh": "\u76d8\u9f99\u6602\u9996\u53d1\u5578"
},
{
"id": "v3",
"index": 3,
"filename": "variants/dragon_roar_v3.png",
"description_zh": "\u9f99\u9996\u55b7\u51fa\u51b2\u51fb\u543c\u58f0"
}
]
},
{
"id": "huangdi_attack",
"name_zh": "\u9ec4\u5e1d\u653b\u52bf",
"effect_zh": "\u517d\u5f15\u63d0\u4f9b\u653b\u51fb\u5411\u589e\u76ca\u3002",
"variants": [
{
"id": "v1",
"index": 1,
"filename": "variants/huangdi_attack_v1.png",
"description_zh": "\u9f99\u722a\u4e09\u8fde\u6495\u88c2"
},
{
"id": "v2",
"index": 2,
"filename": "variants/huangdi_attack_v2.png",
"description_zh": "\u9f99\u9996\u5f15\u5bfc\u7684\u4e0a\u5347\u67aa\u950b"
},
{
"id": "v3",
"index": 3,
"filename": "variants/huangdi_attack_v3.png",
"description_zh": "\u9f99\u9996\u7a81\u51fb\u4e0e\u65a9\u51fb\u7206\u70b9"
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

View File

@ -0,0 +1,15 @@
{"object_id": "clear_beast_guide", "object_name_zh": "\u6e05\u9664\u517d\u5f15", "variant_id": "v1", "prompt_zh": "\u6e05\u9664\u517d\u5f15\uff0c\u517d\u5f15\u4fe1\u6807\u88ab\u65a9\u65ad\uff0c\u5e26\u722a\u5370\u548c\u5c01\u5370\u73af\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "clear_beast_guide", "object_name_zh": "\u6e05\u9664\u517d\u5f15", "variant_id": "v2", "prompt_zh": "\u6e05\u9664\u517d\u5f15\uff0c\u517d\u5f15\u7b26\u6869\u88ab\u7834\u574f\u5e76\u4ece\u5730\u9762\u62d4\u8d77\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "clear_beast_guide", "object_name_zh": "\u6e05\u9664\u517d\u5f15", "variant_id": "v3", "prompt_zh": "\u6e05\u9664\u517d\u5f15\uff0c\u517d\u722a\u5c01\u5370\u88ab\u53cc\u65a9\u6e05\u9664\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "two_gates_pengzu", "object_name_zh": "\u4e24\u95e8\u7684\u5f6d\u7956", "variant_id": "v1", "prompt_zh": "\u4e24\u95e8\u7684\u5f6d\u7956\uff0c\u53cc\u95e8\u524d\u7684\u864e\u9762\u5f6d\u7956\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "two_gates_pengzu", "object_name_zh": "\u4e24\u95e8\u7684\u5f6d\u7956", "variant_id": "v2", "prompt_zh": "\u4e24\u95e8\u7684\u5f6d\u7956\uff0c\u5f6d\u7956\u7a7f\u8fc7\u53cc\u95e8\u7684\u517d\u5f15\u59ff\u6001\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "two_gates_pengzu", "object_name_zh": "\u4e24\u95e8\u7684\u5f6d\u7956", "variant_id": "v3", "prompt_zh": "\u4e24\u95e8\u7684\u5f6d\u7956\uff0c\u95e8\u73af\u4e0e\u5f6d\u7956\u864e\u9762\u7684\u72b6\u6001\u7b26\u53f7\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "pengzu_guard", "object_name_zh": "\u5f6d\u7956\u5b88\u52bf", "variant_id": "v1", "prompt_zh": "\u5f6d\u7956\u5b88\u52bf\uff0c\u864e\u9762\u7f6e\u4e8e\u76fe\u4e2d\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "pengzu_guard", "object_name_zh": "\u5f6d\u7956\u5b88\u52bf", "variant_id": "v2", "prompt_zh": "\u5f6d\u7956\u5b88\u52bf\uff0c\u53cc\u95e8\u95ed\u5408\uff0c\u864e\u76ee\u5b88\u62a4\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "pengzu_guard", "object_name_zh": "\u5f6d\u7956\u5b88\u52bf", "variant_id": "v3", "prompt_zh": "\u5f6d\u7956\u5b88\u52bf\uff0c\u5f6d\u7956\u722a\u5370\u538b\u5728\u57ce\u5899\u76fe\u9762\u4e0a\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "dragon_roar", "object_name_zh": "\u5de8\u9f99\u4e4b\u5578", "variant_id": "v1", "prompt_zh": "\u5de8\u9f99\u4e4b\u5578\uff0c\u4fa7\u9762\u9f99\u9996\u5f20\u53e3\u5486\u54ee\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "dragon_roar", "object_name_zh": "\u5de8\u9f99\u4e4b\u5578", "variant_id": "v2", "prompt_zh": "\u5de8\u9f99\u4e4b\u5578\uff0c\u76d8\u9f99\u6602\u9996\u53d1\u5578\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "dragon_roar", "object_name_zh": "\u5de8\u9f99\u4e4b\u5578", "variant_id": "v3", "prompt_zh": "\u5de8\u9f99\u4e4b\u5578\uff0c\u9f99\u9996\u55b7\u51fa\u51b2\u51fb\u543c\u58f0\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "huangdi_attack", "object_name_zh": "\u9ec4\u5e1d\u653b\u52bf", "variant_id": "v1", "prompt_zh": "\u9ec4\u5e1d\u653b\u52bf\uff0c\u9f99\u722a\u4e09\u8fde\u6495\u88c2\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "huangdi_attack", "object_name_zh": "\u9ec4\u5e1d\u653b\u52bf", "variant_id": "v2", "prompt_zh": "\u9ec4\u5e1d\u653b\u52bf\uff0c\u9f99\u9996\u5f15\u5bfc\u7684\u4e0a\u5347\u67aa\u950b\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}
{"object_id": "huangdi_attack", "object_name_zh": "\u9ec4\u5e1d\u653b\u52bf", "variant_id": "v3", "prompt_zh": "\u9ec4\u5e1d\u653b\u52bf\uff0c\u9f99\u9996\u7a81\u51fb\u4e0e\u65a9\u51fb\u7206\u70b9\uff1b256x256\uff0c\u900f\u660e\u80cc\u666f\uff0c\u767d\u8272\u5355\u8272\u526a\u5f71\uff0c\u65e0\u6587\u5b57\uff0c\u65e0\u6570\u5b57\uff0c\u65e0\u8fb9\u6846\uff1b\u4e2d\u7b49\u7ec6\u8282\uff0c\u4fdd\u7559\u6e05\u6670\u5927\u5f62\uff0c\u4e0d\u4f7f\u7528\u7070\u5ea6\u3001\u9634\u5f71\u3001\u6e10\u53d8\u3002"}

View File

@ -0,0 +1,27 @@
{
"schema": "th1-skill-icon-review-state-v1",
"run_id": "20260626-160741-kasen-icons-readable",
"created_at": "2026-06-26T16:07:41+08:00",
"objects": {
"clear_beast_guide": {
"selected_variant": null,
"feedback": ""
},
"two_gates_pengzu": {
"selected_variant": null,
"feedback": ""
},
"pengzu_guard": {
"selected_variant": null,
"feedback": ""
},
"dragon_roar": {
"selected_variant": null,
"feedback": ""
},
"huangdi_attack": {
"selected_variant": null,
"feedback": ""
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -0,0 +1,3 @@
# 本轮反馈意见
第二轮:根据反馈提高细节量,不再采用过度几何化的极简符号。请记录每个对象选择的方案和修改意见。

View File

@ -77,6 +77,22 @@
"status": "generated",
"created_at": "2026-06-22T15:36:54+08:00",
"summary": "Three local white-only transparent PNG silhouette previews for girl oni form: girl head, oni horns, oni face negative space."
},
{
"id": "preview-kasen-beast-guide-icons-20260626",
"title": "Preview: Kasen beast guide icon drafts",
"path": "hakurei/20260626-155915-kasen-icons",
"status": "generated",
"created_at": "2026-06-26T15:59:15+08:00",
"summary": "Fifteen local white-only transparent PNG variants for Kasen beast guide clear, Pengzu defense, and dragon attack icons."
},
{
"id": "preview-kasen-readable-icons-20260626",
"title": "Preview: Kasen readable medium-detail icon drafts",
"path": "hakurei/20260626-160741-kasen-icons-readable",
"status": "generated",
"created_at": "2026-06-26T16:07:41+08:00",
"summary": "Second pass after feedback: 15 white-only transparent PNG variants with clearer beast guide, Pengzu, dragon roar, and Huangdi attack silhouettes."
}
]
},
@ -112,4 +128,4 @@
]
}
]
}
}

View File

@ -0,0 +1,101 @@
# TH1-CI-2026-06-25-004 Aunn Skill Source And Runtime Buff Conflation
- Status: fixed after correcting Aunn 331/332/342/344 source-buff map; Unity gameplay validation pending
- First recorded date: 2026-06-25
- Last updated: 2026-06-26
- Severity: Critical
## Raw Symptom
Player feedback across 2026-06-25 and 2026-06-26 showed repeated Aunn regressions:
- Lv3 damage-bearing ability was missing or not visible on Aunn.
- Lv2 twin behavior regressed when Aunn actions were changed.
- Recover did not trigger paired Aunn auto-petrify.
- Komainu Guardian, Aunn Approach, and Komainu Substitute Guard were mixed across hero source skills, dynamic buffs, board status icons, and combat predicates.
- Aunn's own petrified state, `AunnPetrifiedState(296)`, appeared with a layer/level marker even though "Ungyo Stone Guardian" is a permanent state with no displayed stacks.
- The final corrected design requires two different same-name skills for Komainu Substitute Guard: `AunnHeroDamageBearer(342)` is the Lv3+ Aunn Unique/source skill, and `AunnHeroDamageBearerBuff(344)` is the real-time Positive beneficiary buff on adjacent non-Aunn allies while that Aunn is petrified.
## Confirmed Violation
This work violated the `th1-hero-implementation` mandatory hero spec gate. The agent did not confirm a skill source map before editing data and guardrails.
The missing distinction was:
- Hero source skill: owned by Aunn, configured through `UnitTypeDataAssets`, visible in hero skill/detail surfaces as part of the hero design.
- Runtime buff/status: generated or removed by skill logic on affected units, carried by the beneficiary unit, and not configured in Aunn's default skill row unless the same `SkillType` is explicitly defined as both source and beneficiary buff.
## Corrected Contract
| Concept | SkillType | Carrier | Configured in Aunn default skill row | Runtime add/remove | UnitMono display |
| --- | --- | --- | --- | --- | --- |
| Komainu Guardian buff | `AunnPetrifiedDefenseAura(331)` | Adjacent allied beneficiary `UnitData` | No | Yes | Yes |
| Aunn Approach source/hidden state | `AunnPortalState(332)` | Aunn source and eligible beneficiary state | Lv2-Lv4 source only | Yes for eligible units | No |
| Komainu Substitute Guard source | `AunnHeroDamageBearer(342)` | Lv3+ Aunn source `UnitData` | Lv3-Lv4 source only | No | No |
| Komainu Substitute Guard buff | `AunnHeroDamageBearerBuff(344)` | Adjacent non-Aunn allied beneficiary `UnitData` | No | Yes for eligible beneficiaries | Yes |
Current row rule:
- Aunn Lv1 default skills include `330`; they must not include `331`, `332`, `342`, runtime buff `344`, or removed `343`.
- Aunn Lv2 default skills add `297`, `298`, and `332`; they must not include `331`, `342`, runtime buff `344`, or removed `343`.
- Aunn Lv3 default skills add source `342`; they must not include `331`, runtime buff `344`, or removed `343`.
- Aunn Lv4 keeps `299`; it must not include `331`, runtime buff `344`, or removed `343`.
- Adjacent non-Aunn allied beneficiaries dynamically gain Positive buff `344` only while near a Lv3+ petrified Aunn, and lose it when that real-time condition stops.
## Root Cause
The root cause was process failure:
- The implementation continued after the user said the Aunn design was misunderstood.
- The mandatory hero spec gate was not restarted and confirmed.
- The guardrail originally encoded the wrong contract around source skills and runtime buffs.
- The follow-up guardrail still encoded the wrong contract by treating same display name as same `SkillType`.
- The code treated row membership as proof of source-skill visibility and runtime buff existence.
- `AunnPetrifiedStateSkill` used `Level` as an internal boolean state but still set `IsLevelSkill = true`, causing generic skill UI to display it as a stacked/leveled skill.
## Root-Cause Fix
Corrected the split skill model:
- Removed the mistaken `SkillType.AunnKomainuGuardian = 343`.
- Removed `AunnKomainuGuardianSkill` and its MemoryPack union/partial generation files.
- Removed `343` from Aunn Lv1-Lv4 source/export UnitType rows.
- Removed `343` from source/export `SkillDataAssets`.
- Kept `331` as the real-time generated +1 Defense buff applied by `SyncAunnPetrifiedDefenseAuras`.
- Kept `332` as Aunn Approach: Lv2+ Aunn source plus hidden beneficiary state.
- Kept `342` as Lv3+ Komainu Substitute Guard Unique/source skill on Aunn only.
- Added `344` as the same-name Positive runtime buff on protected non-Aunn beneficiaries.
- Corrected `Tools/CheckAunnPetrifiedDefenseAura.ps1` to fail if `342` is added to beneficiaries, if `344` is configured in Aunn default skills, if `344` lacks Positive/UnitMono config, or if removed `343` returns.
- Changed `AunnPetrifiedStateSkill` to keep its internal 0/1 state while overriding displayed level/stack semantics to false, and normalized old saved/copied instances after deserialization.
## Guardrail
`Tools/CheckAunnPetrifiedDefenseAura.ps1 -CheckExport` now verifies:
- Aunn default rows do not contain runtime buff `331`.
- Aunn default rows do not contain runtime buff `344` or removed source skill `343`.
- Runtime code does not contain `AunnKomainuGuardian`.
- SkillDataAssets does not contain `SkillType: 343`.
- Lv2-Lv4 contain `332` at the correct levels.
- Lv3-Lv4 contain source `342` at the correct levels.
- `331` remains a board-visible runtime defense buff.
- `342` remains an Aunn Lv3+ Unique/source skill with `ShowOnUnitMono: 0`.
- `344` remains a board-visible Positive runtime beneficiary buff with `ShowOnUnitMono: 1`.
- Runtime damage-bearing logic is not hero-only and does not protect another Aunn.
- Export multilingual rows for removed `343` are absent.
- `AunnPetrifiedStateSkill` cannot reintroduce `IsLevelSkill = true`, must hide displayed level/stack UI, and must normalize deserialized legacy instances.
## Verification Performed
- `Tools\CheckAunnPetrifiedDefenseAura.ps1 -CheckExport`
- `Tools\CheckAunnTwinAutoPetrify.ps1`
- `Tools\CheckSkillDataAssetsIntegrity.ps1 -CheckExport`
- `dotnet build Unity/TH1.Hotfix.csproj --no-restore`
- `dotnet build Unity/TH1.Logic.Editor.csproj --no-restore`
## Remaining Validation Gaps
- Unity Editor gameplay validation: adjacent allied units receive runtime buff `331` only while eligible.
- Unity Editor gameplay validation: adjacent non-Aunn allies receive runtime buff `344` only while near a Lv3+ petrified Aunn, and damage is redirected to that Aunn.
- Unity Editor gameplay validation: Aunn itself does not carry `331` or removed `343` as a default skill.
- Unity Editor gameplay validation: Lv3+ Aunn shows Komainu Substitute Guard as the separate damage-bearing Unique/source ability, but does not show source `342` as a board status icon.

View File

@ -0,0 +1,91 @@
# TH1-CI-2026-06-25-002 Aunn Twin Auto-Petrify Action Trigger
- Status: fixed in code; guardrail expanded; Unity gameplay validation pending
- First recorded date: 2026-06-25
- Severity: High
## Raw Symptom
Player feedback: before Aunn reaches Lv4, when two Aunn bodies exist, after one Aunn takes any action, the other Aunn should automatically recover, lose all action points, and enter petrified state. This behavior was previously present, but currently only movement triggered it.
Follow-up player feedback on 2026-06-25: in actual Lv2 gameplay, after one Aunn acts, the paired Aunn still does not become petrified.
Second follow-up player feedback on 2026-06-25: after the Lv2 pair generates the second Aunn body, the newly generated Aunn has no common action point, even though the design says it can act immediately.
Third follow-up player feedback on 2026-06-25: after one Aunn performs the `Recover` action, the paired Aunn still does not automatically recover, clear action points, and petrify.
Fourth follow-up player feedback on 2026-06-26: with two Aunn on the board, each movement/attack/movement by one Aunn repeatedly triggered the paired Aunn's recover effect.
Affected entrypoint:
- `Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs`
- `AunnTwinBodySkill.OnActionExecuted`
- `HakureiNorwayHeroSkillUtil.TrySpawnAunnTwin`
## Why This Recurs
Aunn's pre-Lv4 two-body rule is implemented in a broad `OnActionExecuted` lifecycle hook, but the regression was introduced by narrowing the trigger to a specific action type. Hero lifecycle code is easy to overfit to the path most recently tested, especially when movement, attack, manual actions, and utility action-point controls all share the same post-action callback.
Without a guardrail, a future refactor can accidentally reintroduce a movement-only predicate or fail to preserve the twin-owner id even though the design rule is "one Aunn acts, the other Aunn waits in petrified state" for any successful Aunn action before Lv4.
## Root Cause
`AunnTwinBodySkill.OnActionExecuted` returned early unless `logic.ActionId.ActionType == CommonActionType.UnitMove`. As a result, attack, capture, recover, manual petrify, and other successful Aunn actions no longer caused the paired Aunn to recover, clear action points, and petrify.
The follow-up root cause was in Aunn twin synchronization. Lv2 `UnitTypeDataAssets` already includes `AunnTwinBody`, so a newly created or transformed Aunn can already hold the skill with `OriginId == self.Id`. `AddAunnSkillIfMissing` returned early when the skill existed, so it did not rewrite the twin body's owner id to the primary Aunn id. `FindAunnTwin` then failed because it requires `AunnTwinBody.AunnOwnerId == primaryAunn.Id`.
The second follow-up root cause was an over-broad post-action lifecycle. `TrySpawnAunnTwin` correctly granted the newly generated twin `ActionPointType.Common`, but the same action's later `AunnTwinBodySkill.OnActionExecuted` callback treated the spawn/upgrade/move action as if the two-body pair had already existed and one Aunn had just acted. It immediately routed the new twin through `SetAunnPetrifiedAndClearActionPoint`, so the action point was added and then cleared in the same action.
The third follow-up root cause was that the one-action spawn skip was keyed only by action-log index. That made the skip too broad: if the marker was still present, a normal later Aunn unit action such as `UnitActionType.Recover` could be skipped even though it is exactly the kind of "one Aunn acted" action that must trigger the pair rule.
The fourth follow-up root cause was missing idempotency in `SetAunnPetrifiedAndClearActionPoint`. `AunnTwinBodySkill.OnActionExecuted` correctly observes every later Aunn action, but the helper unconditionally called `UnitActionRecoverHelper.ExecuteRecover` even when the waiting body was already petrified and had no action points left to consume. That made the recover effect repeat on every later action by the other body.
## Root-Cause Fix
`AunnTwinBodySkill.OnActionExecuted` now derives the acting body and waiting body from the executed action unit instead of requiring `CommonActionType.UnitMove`.
`AddAunnSkillIfMissing` now treats `AunnTwinBody` specially: if the skill already exists, it still calls `AddOrOverrideSkill` to refresh `OriginId`. This keeps the primary body owner id as itself and the twin body owner id as the primary Aunn id.
`TrySpawnAunnTwin` now marks the current action on the primary Aunn's `AunnTwinBodySkill` after creating the twin and before granting the common action point. `AunnTwinBodySkill.OnActionExecuted` consumes that temporary marker once and skips the automatic paired petrify/clear only for the same action that initialized the twin. The next real Aunn action still triggers the pre-Lv4 pair rule.
The spawn-skip marker is now also scoped by action type. It only suppresses auto-petrify for explicit twin-initialization actions: `TrainUnit`, `UnitMove`, and `UnitActionType.HeroUpgrade`. It does not suppress `UnitActionType.Recover`, so recovering with either Aunn before Lv4 now resolves to actor stays/unpetrifies as appropriate and the other body recovers, clears action points, and petrifies.
`SetAunnPetrifiedAndClearActionPoint` is now idempotent: if the waiting Aunn is already petrified and has no action points, it returns before executing recover again.
The restored behavior:
- Lv2 generated twin receives `ActionPointType.Common` and can act immediately after spawn/upgrade.
- Before Lv4, if the primary Aunn acts, the twin recovers, clears all action points, and petrifies.
- Before Lv4, if the twin acts, the primary recovers, clears all action points, and petrifies.
- Before Lv4, `UnitActionType.Recover` is treated as a real Aunn action and must trigger the paired petrify rule.
- Manual `AunnPetrify` does not immediately unpetrify the acting body during the post-action lifecycle.
- Internal `AIParamControl` actions are ignored so AI action-point helper actions do not trigger the two-body rule.
## Guardrail Added
Added `Tools/CheckAunnTwinAutoPetrify.ps1`.
The checker fails if `HakureiNorwayHeroSkill.cs` reintroduces a `CommonActionType.UnitMove`-only gate in the Aunn twin auto-petrify logic, verifies that the code still routes the waiting body through `SetAunnPetrifiedAndClearActionPoint`, and verifies that existing `AunnTwinBody` skills can refresh their owner id instead of being ignored.
The checker also verifies that the generated twin still receives `ActionPointType.Common` and that the code keeps an explicit one-action skip for the twin-spawn initialization action.
The checker now verifies that the one-action skip is routed through `IsAunnTwinSpawnInitializationAction`, that the helper is limited to `TrainUnit`, `UnitMove`, and `HeroUpgrade`, and that `UnitActionType.Recover` is not allowed to become a spawn-initialization action.
The checker now also verifies the idempotency guard that prevents repeated recover when the waiting body is already petrified and has no action point.
`Tools/GitCheckpoint.ps1` now runs this checker when either of these files changes:
- `Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs`
- `Tools/CheckAunnTwinAutoPetrify.ps1`
## Verification Performed
- `Tools/CheckAunnTwinAutoPetrify.ps1` passes.
- `dotnet build Unity/TH1.Hotfix.csproj --no-restore` passes with 0 errors. The build currently reports existing warnings unrelated to this Aunn guardrail.
## Remaining Validation Gaps
- Unity Editor gameplay validation: Aunn Lv2/Lv3 with two bodies should trigger the paired recovery, action-point clear, and petrification after movement, attack, capture/recover, and manual petrify.
- Unity Editor gameplay validation: Aunn Lv2/Lv3 with two bodies should specifically validate `Recover` immediately after twin generation and after at least one later turn.
- Unity Editor gameplay validation: Aunn Lv2 generated twin should keep one common action point immediately after training/spawn and hero upgrade.
- Unity Editor gameplay validation: Aunn Lv4 should keep both bodies independently controllable and should not auto-petrify the pair after either body acts.

View File

@ -0,0 +1,69 @@
# TH1-CI-2026-06-25-001 Culture Upgrade Marker Config Guard
- Status: fixed in source config; guardrail added; export and Unity gameplay validation pending
- First recorded date: 2026-06-25
- Severity: High
## Raw Symptom
Player feedback: after Hakurei Empire selected Feudal Fief, Round Shieldman did not show the culture upgrade action.
Affected paths:
- `Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset`
- `Unity/Assets/BundleResources/Export/UnitTypeDataAssets.asset`
- `Unity/Assets/Scripts/TH1_Logic/Action/UnitActionLogic.cs`
## Why This Recurs
Culture upgrade eligibility is represented indirectly by skills. A unit does not expose the culture upgrade action just because it is an ordinary combat unit or because Feudal Fief is active. The unit must carry a prepare-officer skill such as `SkillType.OFFICER` or `SkillType.JUNKEROFFICER`.
When new civilization-specific units are added by copying a base unit and replacing the skill list, it is easy to keep the visible combat skill but accidentally drop the invisible upgrade marker unless the officer design is checked explicitly.
Existing non-officer unit designs other than Round Shieldman are considered intentional unless their design is changed. This record is not a request to retrofit every old special unit with `OFFICER`.
## Root Cause
Hakurei Round Shieldman was introduced with `FORTIFY` and `HakureiRoundShieldWall`, but without `OFFICER`. `UnitActionCultureUnitUpgrade.CheckShow` calls `UnitData.IsPrepareOfficer()`, so the action was filtered out before cost calculation.
## Root-Cause Fix
Round Shieldman now includes `SkillType.OFFICER` in source `UnitTypeDataAssets.asset`:
- `0800000034010000` -> `0800000034010000b6000000`
## Guardrail Added
Added `Tools/CheckCultureUpgradeUnitConfig.ps1`.
The checker is a guardrail for new or changed ordinary land combat pawn units that are expected to support Feudal Fief culture upgrade:
- `PawnWarrior`
- `PawnArcher`
- `PawnDefender`
- `PawnRider`
- `PawnKnight`
- `PawnCatapult`
- `PawnSword`
Each target must carry a prepare-officer skill:
- `SkillType.OFFICER`
- `SkillType.JUNKEROFFICER`
Intentional non-upgrade designs must stay explicit in the script when they match the broad shape of an ordinary land unit. Current explicit non-upgrade design:
- `KaguyaFrenchAnimalWarrior`, because it is an animal-resource conversion unit, not a normal Feudal Fief upgrade target.
`Tools/GitCheckpoint.ps1` now runs this checker when `UnitTypeDataAssets.asset` or the checker itself is touched. If the exported `UnitTypeDataAssets.asset` is touched, the checkpoint also runs export sync validation with `-CheckExport`.
## Verification Performed
- `Tools/CheckCultureUpgradeUnitConfig.ps1` passes against source `UnitTypeDataAssets.asset`.
- `Tools/CheckCultureUpgradeUnitConfig.ps1 -CheckExport` detects the currently stale export value for Round Shieldman, as expected before normal export refresh.
- `dotnet build Unity/TH1.Hotfix.csproj --no-restore` passed after the source config fix.
## Remaining Validation Gaps
- Refresh `Unity/Assets/BundleResources/Export/UnitTypeDataAssets.asset` through the normal Unity export workflow before packaging.
- Unity Editor gameplay validation: Hakurei Empire selects Feudal Fief, creates Round Shieldman, and confirms the culture upgrade action appears and consumes the expected culture cost.

View File

@ -0,0 +1,99 @@
# TH1-CI-2026-06-25-003 DataAsset Export Text and Reference Corruption
- Status: fixed in assets; guardrails expanded; Unity export/runtime validation pending
- First recorded date: 2026-06-25
- Severity: Critical
## Raw Symptom
User feedback: DataAsset and Export were completely broken again.
Follow-up user feedback on the same day: icons were empty or missing, and text was corrupted or nonsensical.
Affected paths:
- `Unity/Assets/BundleResources/DataAssets/SkillDataAssets.asset`
- `Unity/Assets/BundleResources/Export/SkillDataAssets.asset`
- `Unity/Assets/BundleResources/Export/Multilingual.asset`
- `Unity/Assets/BundleResources/DataAssets/ActionDataAssets.asset`
- `Unity/Assets/BundleResources/Export/ActionDataAssets.asset`
- `Unity/Assets/BundleResources/DataAssets/TechDataAssets.asset`
- `Unity/Assets/BundleResources/Export/TechDataAssets.asset`
- `Tools/MultilingualTxt.txt`
- `Tools/Multilingual.xlsx`
- `Tools/CheckSkillDataAssetsIntegrity.ps1`
- `Tools/CheckUnityAssetReferenceIntegrity.ps1`
- `Tools/GitCheckpoint.ps1`
## Why This Recurs
`SkillDataAssets` is a Unity YAML asset with multilingual strings embedded next to serialized fields. If a string field is corrupted or saved with bad escaping, the next serialized fields can be swallowed into a multilingual text value. The one-click export then treats that corrupted value as normal Chinese text and writes it into the multilingual table before any export asset mismatch is detected.
Without a pre-export source validation step, the export pipeline can turn one broken DataAsset row into three broken surfaces: source DataAsset, exported DataAsset, and multilingual export tables.
The same recurrence pattern also applies to Unity object references. If DataAssets or Export carry a non-empty GUID whose `.meta` file no longer exists, Unity can deserialize the field as a missing object/null icon. Before this fix, no global checkpoint enforced that all DataAssets/Export object references resolve.
## Root Cause
`SkillDataAssets.SkillInfoList[259].SkillDesc` for the Aunn petrified state carried leaked YAML fields such as `NotShow`, `ShowOnUnitMono`, `SkillIcon`, reserve flags, and the start of `SkillType: 297`. The multilingual export registered that leaked text as ID `21861`, which polluted `Export/Multilingual.asset`, `Tools/MultilingualTxt.txt`, and the generated Excel workbook.
The later icon/text corruption was the same class of workflow problem:
- Aunn content introduced by `8f9d23e0c` included bad Chinese copy and initially wrong icon wiring for `AunnPortalState`.
- The follow-up fix `79ac873c` added targeted SkillDataAssets checks, but those checks did not scan every DataAssets/Export object reference.
- `ActionDataAssets` and `TechDataAssets` contained historical non-empty GUID references whose `.meta` files were gone, so UI icons could resolve as missing.
## Root-Cause Fix
The source and exported `SkillDataAssets` now contain the full expected 290 skill rows, including SkillTypes `296-332`.
The polluted multilingual item `21861` was removed from:
- `Unity/Assets/BundleResources/Export/Multilingual.asset`
- `Tools/MultilingualTxt.txt`
- `Tools/Multilingual.xlsx`
Aunn skill icon/text rows were corrected in source and export data.
Missing action and tech icon references were corrected:
- Mountain-defense icon data now points at existing GUID `f3336b9c860ff9348bac821b81199e73`.
- Speed-up icon data now points at existing GUID `80c0657194d758e44872047bf99a6fe9`.
- Deprecated `TechAtom: 112` placeholder icon was cleared to `{fileID: 0}`.
## Guardrail Added
`MultilingualEditorWindow` now validates `SkillDataAssets` before traversing multilingual fields. It fails the export if:
- required late-game SkillType rows are missing,
- a SkillType row is duplicated,
- `SkillName`, `SkillDesc`, or `SkillShowList` text contains serialized field tokens such as `SkillIcon:`, `ReserveOnCarry:`, or `- SkillType:`.
Added `Tools/CheckSkillDataAssetsIntegrity.ps1`.
The checker validates source and optional export files, scans multilingual export surfaces for serialized field token leakage, fails if polluted ID `21861` reappears, validates required Aunn icon GUIDs, and rejects known bad Chinese text tokens such as `并非附近` and `再附近`.
Added `Tools/CheckUnityAssetReferenceIntegrity.ps1`.
The checker scans `Unity/Assets/BundleResources/DataAssets` and `Unity/Assets/BundleResources/Export`, loads all Unity `.meta` GUIDs under `Unity/Assets`, and fails if any non-empty Unity object reference points at a missing GUID.
`Tools/GitCheckpoint.ps1` now runs these checkers when SkillDataAssets, SkillDataAssets export, multilingual export text, DataAssets, Export assets, or either checker changes.
## Verification Performed
- `Tools/MultilingualTxt.txt` was reduced to the valid IDs `21860` and `21862`.
- `Tools/ExportStringToExcel.py` regenerated `Tools/Multilingual.xlsx` from the cleaned TXT.
- `Tools/CheckSkillDataAssetsIntegrity.ps1 -CheckExport` passed.
- `Tools/CheckUnityAssetReferenceIntegrity.ps1` passed.
- Full DataAssets/Export GUID scan found zero missing non-empty references after the fix.
- `Tools/GitCheckpoint.ps1` passed.
- `dotnet build Unity/TH1.Hotfix.csproj --no-restore` passed.
- `dotnet build Unity/TH1.Editor.csproj --no-restore` passed.
- `dotnet build Unity/TH1.Logic.Editor.csproj --no-restore` passed.
## Remaining Validation Gaps
- Unity Editor validation: run one-click export/import and confirm it fails early if a corrupted SkillDataAssets text field is introduced.
- Unity Editor validation: confirm it fails early if a DataAssets/Export asset contains a non-empty missing GUID reference.
- Unity Editor validation: inspect Aunn/Suika/Reimu late skill icons and descriptions in game UI after export.
- Unity Editor validation: inspect action and tech UI entries that previously had missing icon GUIDs.

View File

@ -0,0 +1,62 @@
# TH1-CI-2026-06-26-002 Aunn Shared Health Damage Bearer Sync
- Status: fixed in code; guardrail added; Unity gameplay validation pending
- First recorded date: 2026-06-26
- Severity: Critical
## Raw Symptom
Player feedback on 2026-06-26: when Aunn takes damage in place of another unit, the paired Aunn does not lose the corresponding HP. Aunn's two bodies are supposed to keep HP completely synchronized in every case.
Affected paths:
- `UnitLogic.DamageSettlement`
- `SettlementInfo.DamageBearer`
- `AunnHeroDamageBearerBuffSkill.BeforeDamagedSupportStage`
- `AunnSharedHealthSkill`
## Why This Kept Recurring
Previous fixes treated "shared HP" as extra work attached to individual lifecycle callbacks. That made the implementation depend on which object happened to be considered the damage target. Substitute damage changed the real HP owner from `DamageTarget` to `DamageBearer`, but `AunnSharedHealthSkill` still mostly synchronized from `DamageTarget` and even rejected the global `DamageBearer` path when `DamageTarget` differed from `self`.
The recurring mistake was not defining Aunn shared HP as an invariant over the actual mutated unit. Every new damage, heal, redirect, death, upgrade, or spawn path could bypass one of the patched branches.
## Root Cause
`DamageSettlement` redirects HP loss to `SettlementInfo.DamageBearer` for substitute damage. `AunnSharedHealthSkill.OnDamaged` synchronized `info.DamageTarget`, while `OnUnitDamaged` required `info.DamageTarget?.Id == self.Id`. When a petrified Lv3+ Aunn bore damage for a non-Aunn ally, the real HP loss happened on `DamageBearer`, but the shared-health sync ignored that bearer because the original `DamageTarget` was the protected ally.
The implementation also only wrote the paired body's `Health` value. If synchronization reduced the pair to 0 HP, that could leave a dead-but-not-removed zombie unit instead of running the shared death cleanup.
## Root-Cause Fix
Shared HP is now centralized in `HakureiNorwayHeroSkillUtil`:
- Damage sync uses the real HP owner: `DamageBearer ?? DamageTarget`.
- Heal sync uses the healed Aunn as the source of truth.
- Pair lookup no longer depends on the damaged body still being alive or still in `UnitMap` as the active target.
- Shared-health writes go through one helper that clamps HP, refreshes visuals, and removes the paired body when sync reduces it to 0 HP.
- `UnitLogic.DamageSettlement` and both heal entrypoints call the shared-health helper as an invariant-level fallback, so the sync is not only dependent on `AunnSharedHealthSkill` callbacks.
## Guardrail Added
Added `Tools/CheckAunnSharedHealthSync.ps1`.
The checker fails if:
- `AunnSharedHealthSkill` goes back to syncing from `DamageTarget` only.
- `OnUnitDamaged` reintroduces the `DamageTarget == self` condition that excludes substitute damage.
- paired HP is written directly inside `AunnSharedHealthSkill` instead of through the shared helper.
- the shared helper or shared death cleanup disappears.
## Verification Performed
- `Tools/CheckAunnSharedHealthSync.ps1` passes.
- `Tools/CheckAunnPetrifiedDefenseAura.ps1` passes.
- `Tools/CheckAunnTwinAutoPetrify.ps1` passes.
- `dotnet build Unity/TH1.Hotfix.csproj --no-restore` passes with 0 errors and existing warnings.
## Remaining Validation Gaps
- Unity Editor gameplay validation: Lv3+ petrified Aunn protects an adjacent non-Aunn ally, and both Aunn bodies lose identical HP.
- Unity Editor gameplay validation: if substitute damage kills the bearer Aunn, the paired Aunn is removed as well with no 0-HP unit left on the board.
- Unity Editor gameplay validation: normal damage, splash/true damage, self-recover, ally healing, twin spawn, and hero upgrade all leave the two bodies at equal HP.

View File

@ -0,0 +1,101 @@
# TH1-CI-2026-06-26-001 Suika Falling Splash Landing Animation
- Status: fixed in code; guardrail added; Unity gameplay validation pending
- First recorded date: 2026-06-26
- Severity: High
## Raw Symptom
Player feedback: when Suika Ibuki is Lv.4 and uses the 3-grid self falling splash attack against an enemy unit, if the attack does not kill the target, Suika's own sprite does not visibly move to the landing position. The gameplay logic may move the unit, but the animation is wrong.
Follow-up feedback on the first dedicated Fragment fix: the jump animation was still too fast, the parabola was too low to read as "falling from the sky", and a projectile/bomb trail could still appear during Suika's jump presentation.
Follow-up feedback after the projectile/timing fix: when Suika jumps exactly 3 grids, neither the kill case where Suika lands on the enemy grid nor the non-kill case where Suika moves to an adjacent final grid visibly opens fog around the new landing position.
Affected entrypoint:
- `Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs`
- `Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs`
- `Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentSuikaFallingSplash.cs`
- `Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentAttackGround.cs`
- `Unity/Assets/Scripts/TH1_Anim/UnitAtomAnim/UnitAtomAnimMove.cs`
- `SuikaFallingSplashSkill.AfterActiveAttackOther`
- `HakureiNorwayHeroSkillUtil.TryExecuteSuikaFlyAfterAttack`
## Why This Recurs
Skill-driven displacement inside an attack lifecycle is easy to treat as a data-only change. `UnitLogic.Attack` runs before the attack Fragment is built, so a skill that directly repositions a unit must pass enough presentation data to the later attack Fragment. A final `InstantUpdateUnitPos` refresh is not enough when the unit already has attack-return atom animations queued.
Without a guardrail, future hero skills can again call `SetUnitIdToGridId`, `MoveToLogic`, or a helper reposition during attack settlement and only refresh renderer state at the end, leaving the visible sprite out of sync with the actual data position.
## Root Cause
`TryExecuteSuikaFlyAfterAttack` repositioned Suika during `AfterActiveAttackOther`. The first fix tried to inject a late linear movement/final refresh into the ordinary attack Fragment.
That was still the wrong model for Suika Lv.4. The required presentation has three distinct phases: Suika flies in a parabola from the origin to the target grid, the target grid plays the hit/area splash impact, and only when the target survives does Suika move from the target grid to the final legal landing grid. The ordinary attack Fragment's move-and-return atom sequence does not represent that timeline.
When the target survives, the center target grid remains occupied, so the data layer correctly chooses a nearby landing grid. Without a dedicated Fragment, the renderer never had an explicit "land on target, splash, then move away" sequence.
The first dedicated Fragment still reused global attack timing (`AttackAnimTime` = 0.15s) and a low local arc height, so the jump read as a short hop instead of a sky-fall. The ground-target Suika path also still passed `SkillType.SuikaFallingSplash` through `FragmentAttackGround`, where it was mapped to `ProjectileType.Bomb`, creating a stray projectile.
The next miss was sight presentation. The logic did call `UpdateSightByPath` after Suika's data position changed, but the dedicated Suika Fragment only refreshed origin/target/final grids. It did not carry the set of newly opened sight grids into the landing presentation. For ground self-jump, the new-sight list was even collected after `AttackGroundExecute` had already mutated `SightGidSet`, so the list was empty.
## Root-Cause Fix
Suika falling splash now uses a dedicated animation pipeline:
- Added `UnitAtomAnimType.ParabolaMove` and `UnitAtomAnimParabolaMove`, which lerps between grids while raising Y by a parabola.
- Added `FragmentType.SuikaFallingSplash` and `FragmentSuikaFallingSplash`.
- `TryExecuteSuikaFlyAfterAttack` now records the origin/final landing data on `AttackInfo` instead of injecting a late generic movement helper.
- `UnitAttackAction` switches Suika's Lv.4 falling-splash attack to the dedicated Fragment.
- The Fragment plays the parabola from origin to target, then the target/landing impact, then injected area-damage visuals, then an optional final `UnitAtomAnimType.Move` from target grid to final grid if the target survived.
- The settle step refreshes Suika, origin/target/final grids, related cities, and map highlights.
- Suika's parabola atom now uses a dedicated 1.1s duration and 18-world-unit arc height instead of inheriting the 0.15s attack timing.
- The Fragment waits briefly after splash impact before moving Suika away on non-kill results.
- Suika's self-jump-to-ground path now uses the same dedicated Fragment and data-only reposition, so it does not play `FragmentAttackGround`'s bomb projectile.
- `FragmentAttackGround` also has a defensive guard so `SkillType.SuikaFallingSplash` cannot map to `ProjectileType.Bomb` again.
- `CollectSuikaFallingSplashNewSightGrids` caches the newly opened sight grids before `UpdateSightByPath` mutates `SightGidSet`.
- `AttackInfo.SuikaFallingSplashSightRefreshGrids` carries attack landing sight refresh data into `FragmentSuikaFallingSplashData`.
- Suika's ground self-jump caches the same sight refresh data before `AttackGroundExecute`, then passes it into the dedicated Fragment.
- `FragmentSuikaFallingSplash.RefreshLandingSight` plays fog disappearance, forces grid refresh, and refreshes highlights for every newly opened grid at settle time.
This keeps the authoritative data mutation in the skill logic while making the presentation sequence match the actual player-facing behavior.
## Guardrail Added
Added `Tools/CheckSuikaFallingSplashAnimation.ps1`.
The checker verifies that Suika falling splash still:
- Uses `UnitAtomAnimType.ParabolaMove`.
- Requires the dedicated 1.1s/18-height Suika jump constants.
- Builds `FragmentType.SuikaFallingSplash` from `UnitAttackAction`.
- Builds `FragmentType.SuikaFallingSplash` for Suika self-jump-to-ground.
- Flushes area-damage visual steps into the dedicated Fragment after impact.
- Runs optional final `UnitAtomAnimType.Move` only after the impact phase.
- Rejects any `FragmentAttackGround` mapping from `SkillType.SuikaFallingSplash` to `ProjectileType.Bomb`.
- Refreshes unit position, unit state, origin/landing grids, cities, and highlights after the landing move.
- Keeps `TryExecuteSuikaFlyAfterAttack` ordered as splash damage settlement, new-sight cache, final data reposition, dedicated animation marker, and sight update.
- Verifies attack and ground self-jump both pass sight refresh grids into the dedicated Fragment.
- Verifies the Fragment iterates `Data.SightRefreshGrids` and forces grid refresh for opened fog.
`Tools/GitCheckpoint.ps1` now runs this checker when Suika falling-splash skill/action/Fragment/atom animation files change.
- `Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs`
- `Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs`
- `Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentSuikaFallingSplash.cs`
- `Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentData.cs`
- `Unity/Assets/Scripts/TH1_Anim/UnitAtomAnim/UnitAtomAnimMove.cs`
- `Tools/CheckSuikaFallingSplashAnimation.ps1`
## Verification Performed
- `Tools/CheckSuikaFallingSplashAnimation.ps1` passes.
- `dotnet build Unity/TH1.Hotfix.csproj --no-restore` passes with 0 errors. The build currently reports existing warnings unrelated to this Suika animation fix.
## Remaining Validation Gaps
- Unity Editor gameplay validation: Suika Lv.4, 4 Suika Shadow stacks, attack an enemy exactly 3 grids away and do not kill it; confirm Suika visibly moves from the origin grid to the chosen landing grid and final sprite position matches data.
- Unity Editor gameplay validation: same attack with target killed; confirm the landing animation and death cleanup both play in the correct order.
- Unity Editor gameplay validation: same kill and non-kill cases from fog boundary; confirm newly revealed tiles around Suika's final logical position visibly open.
- Unity Editor gameplay validation: same path with counterattack and with no counterattack, because both Fragment timelines have different step lists.

View File

@ -6,6 +6,12 @@
| ID | 日期 | 状态 | 严重度 | 问题 | 根治方向 | 记录 |
| --- | --- | --- | --- | --- | --- | --- |
| TH1-CI-2026-06-26-002 | 2026-06-26 | Fixed in code; guardrail added; Unity validation pending | Critical | Aunn shared HP ignored `DamageBearer` substitute-damage paths | Treat shared HP as an invariant over the actual mutated unit, `DamageBearer ?? DamageTarget`, with entrypoint fallback and shared-death cleanup | [record](2026-06-26-aunn-shared-health-damage-bearer.md) |
| TH1-CI-2026-06-26-001 | 2026-06-26 | Fixed in code; guardrail added; Unity validation pending | High | Suika Lv4 falling splash can move data without complete landing, projectile, and fog/sight presentation after a 3-grid jump | Use a dedicated Suika falling-splash Fragment that owns jump, splash, final landing, and newly opened fog refresh | [record](2026-06-26-suika-falling-splash-landing-animation.md) |
| TH1-CI-2026-06-25-004 | 2026-06-25 | Fixed after correcting Aunn 331/332/342/344 source-buff map; Unity validation pending | Critical | Aunn hero source skills and same-name runtime buffs were conflated and edited without a confirmed skill source map | Enforce 331 as defense-only runtime buff, 332 as hidden approach state, 342 as Lv3+ Aunn Unique/source skill, and 344 as same-name Positive beneficiary damage-bearing buff | [record](2026-06-25-aunn-lv3-petrified-defense-aura-display.md) |
| TH1-CI-2026-06-25-003 | 2026-06-25 | Fixed in assets; guardrails expanded; Unity validation pending | Critical | DataAssets/Export can silently accept corrupt text and missing Unity object GUIDs | Validate SkillDataAssets text/icon semantics and globally scan DataAssets/Export GUID references from GitCheckpoint | [record](2026-06-25-skill-dataasset-export-yaml-leak.md) |
| TH1-CI-2026-06-25-002 | 2026-06-25 | Fixed in code; guardrail added; Unity validation pending | High | Aunn twin auto-petrify regressed to movement-only trigger before Lv4 | Restore any-successful-Aunn-action trigger and checkpoint the lifecycle predicate | [record](2026-06-25-aunn-twin-auto-petrify-action-trigger.md) |
| TH1-CI-2026-06-25-001 | 2026-06-25 | Guardrail added; export validation pending | High | Civilization-specific ordinary units can lose culture-upgrade eligibility marker | Add source/export config checker for prepare-officer skills and run it from GitCheckpoint | [record](2026-06-25-culture-upgrade-marker-config-guard.md) |
| TH1-CI-2026-06-24-001 | 2026-06-24 | Fixed in code | Medium | Sumireko Lv4 did not upgrade existing Occult Orbs | Sync persistent orb source skills from hero upgrade action and refresh all orb buffs | [record](2026-06-24-sumireko-lv4-existing-orbs.md) |
当前无 active 癌症项。

View File

@ -0,0 +1,186 @@
# Steam Summer Sale 2026 And Pricing Strategy
## Context
- Game: 帝国幻想乡 / TOHOTOPIA
- Steam package ID: 1327906
- Release date: 2026-05-14
- Current approximate metrics:
- Wishlists: 40,000
- Sales: 30,000
- Current base price:
- China: CNY 18
- US: about USD 3.99 / USD 4
- Planned full-release price adjustment is about +40%, not CNY 40:
- China: CNY 18 -> CNY 28
- US: USD 4 -> USD 6
- Product horizon:
- This should be treated as a 5-year product, not a short one-cycle launch.
- The current early-access period is mainly for validation and iteration with core Touhou players.
- The intended full-release strategy is to solve the main blockers to broader breakout first, especially rough art and balance.
- The full-release beat should be saved as the main breakout attempt.
- Current review state:
- Review status is already very strong.
- Before full release, promotion should not be judged only by short-term unit sales.
- Promotion should be used to maintain activity, test content, collect feedback, and preserve long-term pricing power.
## Steam Summer Sale 2026 Status
- Steamworks Discount Management shows the event filter `Steam Summer Sale 2026`.
- The package `帝国幻想乡 - 1327906` is visible under that event.
- The current Summer Sale discount is set to `10%`.
- This is sufficient evidence that the package has been added to the Summer Sale discount event, assuming the Discount Management page has no unsaved-change or submission warnings.
- The Steamworks "Store Packages, Pricing & Release Dates" page is not a reliable place to verify Summer Sale participation. It mainly shows release status, base price, currently effective discounts, and launch discount state.
## Sale Timing
- Steam Summer Sale 2026:
- Pacific time: 2026-06-25 10:00 to 2026-07-09 10:00
- Beijing / Shanghai time: 2026-06-26 01:00 to 2026-07-10 01:00
- Before a discount starts, it can usually be edited or removed in Steamworks Discount Management.
- After a discount starts, Steamworks generally does not allow self-service changes to the discount percentage.
## Wishlist Notification Rule
- Steam wishlist discount notifications require a discount of `20%` or greater to become eligible.
- A `10%` discount can participate in a seasonal sale but generally will not trigger wishlist sale email / mobile notification eligibility.
- A `20%` discount does not guarantee every wishlisting user receives a notification. Delivery can still be affected by user notification settings, Steam scheduling, high-traffic periods, and cooldown behavior.
- The gap from Summer Sale to Autumn Sale is long enough that the usual wishlist notification cooldown is not the main constraint.
## Current Content State
- Summer Sale starts tomorrow.
- The fifth faction is still being developed and tested.
- The fifth hero of the fifth faction is not fully complete today.
- Beta group feedback is still being collected and fixes are still being made.
- The full fifth faction content is expected to be completed and shipped tomorrow.
- The Summer Sale lasts two weeks, but the first one to two days of the fifth faction may still be rough.
## Current Recommendation
- Keep the Summer Sale discount at `10%`.
- Do not change it to `20%` for this Summer Sale unless the fifth faction is already stable enough for a large influx of wishlist users.
- Rationale:
- 40,000 wishlists are a high-value conversion pool.
- The first one to two days of the new faction may still carry avoidable quality risk.
- A `20%` discount would make the game eligible for wishlist notifications and may convert users into a version that is still being fixed.
- A `10%` discount is better for light exposure, sale participation, and content-update visibility without strongly spending the wishlist asset.
## Five-Year Promotion Strategy
The promotion strategy should be split into two phases: pre-full-release validation and full-release breakout.
Before full release, the product does not need to maximize every sale event. The stronger goal is to use the existing core Touhou audience to stress-test design, balance, content cadence, and retention. Since the game is already in a strong review state, careless discounting has less upside than it would for a struggling product. The main risk is training players to wait for deeper discounts, weakening the CNY 28 / USD 6 full-release price anchor, or pulling broader players into a version whose art and balance still block breakout.
Full release should be treated as the first major mass-market conversion moment. That means the pre-release period should preserve:
- Wishlist conversion potential.
- Pricing credibility after the CNY 18 -> CNY 28 and USD 4 -> USD 6 increase.
- A clear "the game is now visibly better" narrative around art, balance, and content completeness.
- Enough announcement and influencer material to make the full-release beat feel different from ordinary EA updates.
## Promotion Purpose Before Full Release
Frequent promotion before full release is still useful, but its meaning should be narrower:
- Keep the Steam algorithm and community activity warm.
- Give core players convenient entry points after meaningful updates.
- Expand the beta-quality feedback pool without overexposing unfinished systems.
- Measure which factions, heroes, screenshots, announcements, and update themes create conversion.
- Maintain visibility for long-tail wishlisters without spending the best conversion moment too early.
It should not be used as the main breakout attempt until art quality and balance have reached the intended full-release bar.
## Summer Sale Execution
- Launch the fifth faction when it is ready, but keep public wording measured.
- Recommended first announcement positioning:
- Fifth faction is online.
- Balance and bug fixes will continue based on beta and player feedback.
- Avoid overselling the faction as final if the first two days still need iteration.
- After the fifth hero is complete and the first round of critical fixes is done, publish a stronger follow-up announcement:
- Full fifth faction introduction.
- Hero and mechanics overview.
- Balance and bug-fix summary.
- Clear reason for players to return during the remaining sale window.
## Discount Rhythm
| Situation | Recommended Discount | Purpose |
| --- | ---: | --- |
| Small update, balance patch, content still being polished | 10% | Light exposure, feedback, and activity without strongly converting wishlists |
| Pre-full-release major update that is still mainly for core players | 10% | Validate content while preserving the full-release breakout beat |
| Stable major update after art / balance blockers are solved | 20% | Trigger wishlist notification eligibility and actively convert demand |
| Full release, winter sale, or a larger mature content package | 20%-25% | Strong conversion while maintaining price integrity after the price increase |
| Early access soon after launch or frequent minor promos | Avoid 30%+ | Avoid weakening the price anchor and training players to wait for deep discounts |
- Since promos are happening roughly every 45 days, avoid using `20%+` every time.
- Prefer:
- Routine small promos before full release: `10%`
- Major pre-release updates that still mainly validate with core players: `10%`
- First broad-market push after art and balance are visibly improved: `20%`
- Full release or rare stronger beats after full release: `20%-25%`
## Pre-Full-Release Campaign Logic
For the period before full release:
- Use `10%` as the default discount.
- Pair each promo with a real update, but do not make every update sound like a final breakthrough.
- Keep announcement copy focused on iteration, new content, balance, and player feedback.
- Watch conversion, playtime, reviews, forum issues, refund reasons, and beta feedback rather than gross sales alone.
- Avoid `20%` unless the update is both stable and representative of the full-release quality target.
The strategic role of these promos is "controlled validation", not "cash out the wishlist".
## Full-Release Breakout Logic
For full release:
- Solve or substantially improve the two current breakout blockers first:
- Rough art.
- Balance.
- Increase base price before the full-release discount plan, respecting Steam price-increase cooldown rules.
- Build the full-release message around a visible quality leap, not only new content volume.
- Use a `20%` launch or seasonal discount only if the price-increase cooldown allows it.
- Consider `25%` only if the full-release version is clearly ready for broader non-core players and the marketing assets are strong enough to justify a larger push.
At CNY 28 / USD 6:
- `20%` becomes about CNY 22.4 / USD 4.8.
- `25%` becomes about CNY 21 / USD 4.5.
Both still preserve a healthier post-increase price anchor than discounting deeply from the current CNY 18 / USD 4 base.
## Price Increase Constraints
- A base-price increase in any currency usually creates a 30-day discount cooldown.
- Seasonal sales are also affected by price-increase cooldowns.
- A package with an active or already scheduled discount may also be blocked from base-price edits.
- Therefore, the planned price increase to:
- China: CNY 28
- US: USD 6
should be scheduled outside active discount windows and at least 30 days before the next intended major discount.
## Suggested Roadmap
1. Keep Summer Sale 2026 at `10%`.
2. Ship and stabilize the fifth faction.
3. Use Summer Sale announcements for visibility, but do not aggressively wake the full wishlist pool while content is still rough.
4. Before full release, continue using frequent but mostly light `10%` promos as validation and community-maintenance beats.
5. Focus development on the two breakout blockers:
- Art quality.
- Balance.
6. After those blockers are visibly improved, prepare the full-release announcement, trailer, screenshots, capsule review, and influencer / community push.
7. Before full release, adjust base price:
- China: CNY 18 -> CNY 28
- US: USD 4 -> USD 6
8. Leave at least 30 days after the price increase before scheduling the next discount.
9. Use full release, Autumn Sale, Winter Sale, or a mature post-art/balance update as the first stronger `20%` or `25%` wishlist-conversion moment.
## Bottom Line
- If the goal is long-term review health and price anchoring, this Summer Sale should remain `10%`.
- If the fifth faction were already polished and stable, `20%` would be reasonable.
- Given the current state, a `20%` discount risks converting the 40,000-wishlist pool into a still-rough build. Save the stronger discount for a more stable major beat.
- From the 5-year product view, pre-full-release promotions are for validation and heat maintenance. The breakout discount should wait until rough art and balance are no longer the main blockers.

View File

@ -0,0 +1,100 @@
# TH1-SI-2026-06-25-003 DataAssets/Export Icon and Localization Corruption
- Date: 2026-06-25
- Severity: S1
- Status: fixed in workspace; command-line guardrails passed; Unity runtime validation pending
## Raw Symptom
User reported that DataAssets and Export were completely broken: icons were empty or missing, and text was corrupted or nonsensical.
Affected surfaces:
- `Unity/Assets/BundleResources/DataAssets/SkillDataAssets.asset`
- `Unity/Assets/BundleResources/Export/SkillDataAssets.asset`
- `Unity/Assets/BundleResources/Export/Multilingual.asset`
- `Unity/Assets/BundleResources/DataAssets/ActionDataAssets.asset`
- `Unity/Assets/BundleResources/Export/ActionDataAssets.asset`
- `Unity/Assets/BundleResources/DataAssets/TechDataAssets.asset`
- `Unity/Assets/BundleResources/Export/TechDataAssets.asset`
- `Tools/MultilingualTxt.txt`
- `Tools/Multilingual.xlsx`
## Impact
- Aunn status and portal skills could show wrong or missing icons.
- Aunn skill descriptions contained bad Chinese text, including `并非附近` and `再附近`, changing meaning and polish.
- Action and tech data carried non-empty Unity object references to GUIDs with no matching `.meta`, which can resolve to null icons in UI.
- Source DataAssets and Export copies could diverge or both carry broken references, making manual inspection unreliable.
## Root Cause
This was not one single typo. It was a process failure around DataAsset and Export validation.
1. Commit `8f9d23e0c` introduced the Aunn content update. The new rows included wrong/bad Chinese copy and initially pointed `AunnPortalState` at the wrong icon family.
2. Commit `79ac873c` fixed the Aunn SkillDataAssets icon/text corruption and added targeted SkillDataAssets validation, but the guardrail was still too narrow: it did not globally scan all DataAssets/Export object references for GUIDs that no longer existed.
3. Historical `ActionDataAssets` and `TechDataAssets` rows already contained broken non-empty icon GUID references. Because no global GUID-existence check existed, those references survived until the user hit visible missing icons.
4. The export workflow relied on row-count and known-field validation but did not enforce this invariant: every non-empty Unity object reference in DataAssets and Export must resolve to an existing `.meta` GUID.
## Evidence
Verified Aunn data after the targeted fix:
- `SkillType.AunnPetrifiedState` uses `Skill_AunnPetrifiedState`.
- `SkillType.AunnTwinBody` uses `Skill_AunnTwinBody`.
- `SkillType.AunnSharedHealth` uses `Skill_AunnSharedHealth`.
- `SkillType.AunnTwinOperable` uses `Skill_AunnTwinOperable`.
- `SkillType.AunnPetrifiedDefenseAura` uses `Skill_AunnPetrifiedDefenseAura`.
- `SkillType.AunnPortalState` uses `Skill_AunnPortalTeleport`.
Global DataAssets/Export GUID scan found missing references before the fix:
- `d1151819d2961814bba789be96f4f927` in `ActionDataAssets` mountain-defense icon data.
- `4a8142ccbfb89ce439b20338bc2a4e28` in `ActionDataAssets` speed-up icon data.
- `3b59b6f3313ef514ebfba6066dd6b6f8` in deprecated `TechAtom: 112` placeholder data.
## Fix
Corrected source and exported action data:
- Replaced missing mountain-defense icon GUID `d1151819d2961814bba789be96f4f927` with existing `MountainDefense_128x128.png` GUID `f3336b9c860ff9348bac821b81199e73`.
- Replaced missing speed-up icon GUID `4a8142ccbfb89ce439b20338bc2a4e28` with existing `Skill_MOVERANGEUP.png` GUID `80c0657194d758e44872047bf99a6fe9`.
Corrected source and exported tech data:
- Cleared deprecated `TechAtom: 112` placeholder icon to `{fileID: 0}` because the row is marked as abandoned placeholder data and should not reference an asset.
Added permanent guardrail:
- `Tools/CheckUnityAssetReferenceIntegrity.ps1` scans `Unity/Assets/BundleResources/DataAssets` and `Unity/Assets/BundleResources/Export`.
- It loads all Unity `.meta` GUIDs under `Unity/Assets`.
- It fails if any non-empty `{fileID, guid, type}` reference points at a GUID with no `.meta`.
- `Tools/GitCheckpoint.ps1` now runs this checker when DataAssets, Export assets, or the checker itself change.
## Verification Performed
- `Tools/CheckSkillDataAssetsIntegrity.ps1 -CheckExport`: passed.
- `Tools/CheckUnityAssetReferenceIntegrity.ps1`: passed.
- Full DataAssets/Export GUID scan: zero missing non-empty references.
- `Tools/GitCheckpoint.ps1`: passed.
- `dotnet build Unity/TH1.Hotfix.csproj --no-restore`: passed.
- `dotnet build Unity/TH1.Editor.csproj --no-restore`: passed.
- `dotnet build Unity/TH1.Logic.Editor.csproj --no-restore`: passed.
## Remaining Validation
Unity Editor/runtime validation is still required:
- Open Aunn status and portal UI and confirm icons/text are correct.
- Confirm action buttons for mountain defense and speed-up show icons.
- Confirm tech UI does not show a broken placeholder icon for deprecated `TechAtom: 112`.
- Run the one-click export path in Unity and confirm the new checks fail before export if a broken GUID or known bad SkillDataAssets text is introduced.
## Prevention Rule
Any future DataAsset or Export change must pass both:
- `Tools/CheckSkillDataAssetsIntegrity.ps1 -CheckExport`
- `Tools/CheckUnityAssetReferenceIntegrity.ps1`
`Tools/GitCheckpoint.ps1` is now the required checkpoint entry for these files because it runs both targeted and global checks.

View File

@ -0,0 +1,65 @@
# TH1-SI-2026-06-25-002 Hakurei Round Shieldman Culture Upgrade Missing
## Basic Info
- Severity: S2
- Status: Fixed in workspace; Unity runtime/export validation pending
- First reported: 2026-06-25
- Reporter/source: Player feedback through user
- Affected area: Hakurei Empire unit config, Feudal Fief culture upgrade flow
## Symptom
After Hakurei Empire selects Feudal Fief, Round Shieldmen do not show the culture upgrade action.
Expected behavior: Round Shieldmen are ordinary Hakurei Defender-line units, so after Feudal Fief enables ordinary unit culture upgrades they should expose the culture upgrade action and upgrade by paying culture.
## Root Cause
The culture upgrade action is gated by `UnitData.IsPrepareOfficer()`. That method only returns true when the unit has a skill whose `IsPrepareOfficer()` returns true.
The shared ordinary-unit marker is `SkillType.OFFICER` (`182`, serialized as `b6000000`). Base Defender keeps this marker, but Hakurei Round Shieldman only had:
- `SkillType.FORTIFY` (`08000000`)
- `SkillType.HakureiRoundShieldWall` (`34010000`)
Because Round Shieldman lacked `SkillType.OFFICER`, `UnitActionCultureUnitUpgrade.CheckShow` filtered it out before cost calculation, so the UI never showed the action even though Feudal Fief itself was enabled.
## Introducing Commit
- Commit: `97f05e686481855e67196c6551d7d6626e1fb2a0`
- Author date: 2026-06-18 21:18:39 +0800
- Message: `博丽帝国开发第一阶段`
Evidence:
- `git blame -L 10138,10138 -- Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset` attributes the Round Shieldman skill list to this commit.
- `git log -S "0800000034010000" -- Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset` points to this commit as the introduction of the skill blob.
- `git show 97f05e6864 -- Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset` shows UnitType 54 being introduced as Round Shieldman with the missing marker.
## Fix
Added `SkillType.OFFICER` to Hakurei Round Shieldman in:
- `Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset`
The fixed skill list is now:
- `0800000034010000b6000000`
## Verification
- Confirmed UnitType 54 now contains `SkillType.OFFICER`.
- Confirmed the action gate requires `IsPrepareOfficer()` before the culture upgrade action is shown.
- Confirmed Feudal Fief maps to the culture card implementation that enables ordinary unit culture upgrade.
## Remaining Validation Gaps
- Unity Editor gameplay validation pending: select Hakurei Empire, take Feudal Fief, create a Round Shieldman, and confirm the culture upgrade action appears and consumes the expected culture cost.
- `Unity/Assets/BundleResources/Export/UnitTypeDataAssets.asset` remains stale until the normal export workflow is run. Do not hand-edit this export file.
## Guardrail
When adding a new unit row, explicitly decide whether the unit has officer/culture-upgrade design before finalizing `Skills`. Civilization-specific replacements for ordinary upgradeable units should preserve the ordinary-unit upgrade marker (`SkillType.OFFICER`) unless the design explicitly says the unit cannot use culture upgrade.
This is now enforced by `Tools/CheckCultureUpgradeUnitConfig.ps1`, which is also run from `Tools/GitCheckpoint.ps1` when `UnitTypeDataAssets.asset` or the checker is touched.

View File

@ -0,0 +1,58 @@
# TH1-SI-2026-06-25-001 Suika Upgrade Grants Free Stack
## Basic Info
- Severity: S2
- Status: Fixed in workspace; Unity runtime/export validation pending
- First reported: 2026-06-25
- Reporter/source: User gameplay observation
- Affected area: Hakurei Norway hero config, Suika Ibuki Lv.2 -> Lv.3 upgrade
## Symptom
When Suika Ibuki upgrades from Lv.2 to Lv.3, she immediately gains 1 Suika Shadow stack without a Mini Suika attaching to her.
Expected behavior: Suika Shadow stacks should only be gained through Mini Suika attachment or explicit stack-granting gameplay logic, not from hero level-up config.
## Root Cause
`SkillType.SuikaMiniStack` is a level skill with auto-disappear behavior. The generic `SkillBase.OnSkillAdd` path raises such skills from 0 to 1 stack when they are first added.
The Lv.3 and Lv.4 Suika unit config included `SuikaMiniStack` in the initial skill list, so `UnitTypeTransform` added the skill during hero upgrade and the generic skill add path initialized it to 1 stack.
## Introducing Commit
- Commit: `1519d4cca418ec37d4b153f387203584527878ee`
- Author: daixiawu `<sanzunonyasama@126.com>`
- Author date: 2026-06-25 00:18:42 +0800
- Message: Suika bug fix
Evidence:
- `git log -S "130000002d0100002c0100002e0100002f010000" -- Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset` returns only `1519d4cca418ec37d4b153f387203584527878ee`.
- `git blame -L 8661,8661 -- Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset` attributes the Lv.3 skill list to that commit.
- `git blame -L 8706,8706 -- Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset` attributes the Lv.4 skill list to that commit.
## Fix
Removed `SuikaMiniStack` (`2d010000`, `SkillType` 301) from Suika Lv.3 and Lv.4 initial skill lists in:
- `Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset`
The dynamic stack path remains unchanged: Mini Suika attachment still calls `TryAddMiniSuikaStack`.
## Verification
- Checked current `UnitTypeDataAssets.asset` no longer contains `2d010000` in Suika Lv.3/Lv.4 skill lists.
- Reviewed diff to confirm the targeted removal:
- Lv.3: `130000002d0100002c0100002e0100002f010000` -> `130000002c0100002e0100002f010000`
- Lv.4: `130000002d0100002c0100002e0100002f01000030010000` -> `130000002c0100002e0100002f01000030010000`
## Remaining Validation Gaps
- Unity Editor gameplay validation pending: upgrade Suika Lv.2 -> Lv.3 and confirm Suika Shadow remains at 0 unless a Mini Suika attaches.
- `Unity/Assets/BundleResources/Export/UnitTypeDataAssets.asset` still contains the stale exported values and must be refreshed by the normal export workflow before packaging.
## Guardrail
Do not place auto-disappear stack counter skills in unit initial skill lists unless the design explicitly wants the unit to spawn or upgrade with 1 stack. Dynamic counters should be added only by the gameplay action or skill event that grants the stack.

View File

@ -6,6 +6,9 @@
| ID | 日期 | 严重度 | 状态 | 事件 | 根因提交 |
| --- | --- | --- | --- | --- | --- |
| TH1-SI-2026-06-25-003 | 2026-06-25 | S1 | Fixed in workspace; guardrails passed; Unity runtime validation pending | DataAssets/Export icon references and Aunn localization/icon corruption | `8f9d23e0c`; guardrail gap after `79ac873c`; historical missing GUID refs |
| TH1-SI-2026-06-25-002 | 2026-06-25 | S2 | Fixed in workspace; Unity/export validation pending | Hakurei Round Shieldman lacks Feudal Fief culture upgrade option | `97f05e686481855e67196c6551d7d6626e1fb2a0` |
| TH1-SI-2026-06-25-001 | 2026-06-25 | S2 | Fixed in workspace; Unity/export validation pending | Suika Lv.2 -> Lv.3 grants a free Suika Shadow stack | `1519d4cca418ec37d4b153f387203584527878ee` |
| TH1-SI-2026-06-23-002 | 2026-06-23 | S2 | 已在工作区修复,待 Unity 实机回归 | 博丽奇观按钮显示埃及图标 | 前置 `233029d10c`;触发 `40b5d0d1703757ca08a196e2ab5e2b8b036a407a` |
| TH1-SI-2026-06-23-001 | 2026-06-23 | S1 | 已在工作区修复,待 Unity 实机回归 | 英雄升级错误返还文化值 | 根因 `0b4b5434bc6b83dc47323693488497faeac3880e`;暴露 `8bce7d89dcfcc750c19b8d27f45905fe17849c8c` |

View File

@ -0,0 +1,287 @@
param(
[string]$SkillFile = "Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs",
[string]$StatusAreaFile = "Unity/Assets/Scripts/TH1_Renderer/UnitStatusArea.cs",
[string]$RuntimeDataFile = "Unity/Assets/Scripts/TH1_Data/RuntimeData.cs",
[string]$UnitTypeAsset = "Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset",
[string]$ExportUnitTypeAsset = "Unity/Assets/BundleResources/Export/UnitTypeDataAssets.asset",
[string]$SkillDataAsset = "Unity/Assets/BundleResources/DataAssets/SkillDataAssets.asset",
[string]$ExportSkillDataAsset = "Unity/Assets/BundleResources/Export/SkillDataAssets.asset",
[string]$ExportMultilingualAsset = "Unity/Assets/BundleResources/Export/Multilingual.asset",
[switch]$CheckExport
)
$ErrorActionPreference = "Stop"
$repoRoot = git rev-parse --show-toplevel 2>$null
if (-not $repoRoot) {
throw "Not inside a git repository."
}
$repoRoot = [System.IO.Path]::GetFullPath($repoRoot.Trim())
function Resolve-RepoPath([string]$Path) {
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
}
function Read-TextUtf8([string]$Path) {
$fullPath = Resolve-RepoPath $Path
if (-not (Test-Path -LiteralPath $fullPath)) {
throw "File not found: $fullPath"
}
return [System.IO.File]::ReadAllText($fullPath, [System.Text.Encoding]::UTF8)
}
function Get-AunnRowsByLevel([string]$Path) {
$text = Read-TextUtf8 $Path
$matches = [regex]::Matches(
$text,
'(?ms)^ - UnitType: 14\r?\n GiantType: 24\r?\n UnitLevel: (\d+)\r?\n.*?(?=^ - UnitType: |\z)')
$rows = @{}
foreach ($match in $matches) {
$level = [int]$match.Groups[1].Value
$rows[$level] = $match.Value
}
return $rows
}
function Assert-AunnUnitTypeRows([string]$Label, [string]$Path) {
$rows = Get-AunnRowsByLevel $Path
foreach ($level in 1..4) {
if (-not $rows.ContainsKey($level)) {
throw "$Label is missing Aunn UnitLevel $level row."
}
}
foreach ($level in 1..4) {
if ($rows[$level] -match 'Skills: .*4b010000') {
throw "$Label Aunn Lv$level must not include runtime buff AunnPetrifiedDefenseAura(331) in default skills."
}
if ($rows[$level] -match 'Skills: .*57010000') {
throw "$Label Aunn Lv$level must not include removed source skill AunnKomainuGuardian(343)."
}
if ($rows[$level] -match 'Skills: .*58010000') {
throw "$Label Aunn Lv$level must not include runtime buff AunnHeroDamageBearerBuff(344) in default skills."
}
}
foreach ($level in @(1)) {
if ($rows[$level] -match 'Skills: .*4c010000') {
throw "$Label Aunn Lv$level must not include AunnPortalState(332); Aunn Approach starts at Lv2."
}
if ($rows[$level] -match 'Skills: .*56010000') {
throw "$Label Aunn Lv$level must not include AunnHeroDamageBearer(342); hero damage guard starts at Lv3."
}
}
foreach ($level in @(2, 3, 4)) {
if ($rows[$level] -notmatch 'Skills: .*4c010000') {
throw "$Label Aunn Lv$level must include AunnPortalState(332), the Aunn Approach teleport source skill."
}
}
foreach ($level in @(2)) {
if ($rows[$level] -match 'Skills: .*56010000') {
throw "$Label Aunn Lv$level must not include AunnHeroDamageBearer(342); hero damage guard starts at Lv3."
}
}
foreach ($level in @(3, 4)) {
if ($rows[$level] -notmatch 'Skills: .*56010000') {
throw "$Label Aunn Lv$level must include AunnHeroDamageBearer(342), separate from Komainu Guardian."
}
}
if ($rows[4] -notmatch 'Skills: .*2b010000') {
throw "$Label Aunn Lv4 must still include AunnTwinOperable(299)."
}
Write-Host "$Label Aunn split skill unit rows passed."
}
$skillText = Read-TextUtf8 $SkillFile
$requiredSkillCode = @(
'SyncAunnHeroDamageBearerSourceSkill',
'HasAunnHeroDamageBearerBuffState',
'CanAunnProvideHeroDamageBearer',
'SyncAunnHeroDamageBearerBuffs',
'GetAunnBodyLevel(map, aunn) < 3',
'CanAunnProvidePetrifiedDefenseAura(map, aunn, unit, unitGrid)',
'if (IsAunnBody(unit)) return false;',
'target.AddOrOverrideSkill(SkillType.AunnHeroDamageBearerBuff, map, aunn.Id)',
'unit.RemoveSkill(SkillType.AunnHeroDamageBearerBuff, map)',
'return HakureiNorwayHeroSkillUtil.HasAunnPetrifiedDefenseAuraState(mapData, self) ? 1f : 0f;',
'public partial class AunnHeroDamageBearerSkill',
'public partial class AunnHeroDamageBearerBuffSkill',
'public override void BeforeDamagedSupportStage(MapData mapData, SettlementInfo info)',
'mapData.UnitMap.GetUnitDataByUnitId(OriginId, out var aunn)',
'public partial class AunnPortalStateSkill',
'public static bool IsAunnPortalSource',
'unit.AddOrOverrideSkill(SkillType.AunnPortalState, map, aunn.Id)',
'if (IsAunnPortalSource(map, unit)) continue;'
)
foreach ($needle in $requiredSkillCode) {
if (-not $skillText.Contains($needle)) {
throw "Aunn petrified-defense aura guardrail failed: missing '$needle'."
}
}
if ($skillText -match 'info\.DamageTarget\.IsHero\(\)[\s\S]*?info\.DamageBearer = self;') {
throw "Aunn petrified-defense aura guardrail failed: damage redirection must use CanAunnProvideHeroDamageBearer."
}
if ($skillText.Contains('CanAunnProtectHeroWithPetrifiedDefenseAura') -or
$skillText.Contains('if (!unit.IsHero()) return false;')) {
throw "Aunn hero damage bearer guardrail failed: SkillType 342 must protect adjacent non-Aunn allies, not hero-only targets."
}
if ($skillText.Contains('SyncAunnPetrifiedDefenseAuraSourceSkill') -or
$skillText.Contains('IsAunnPetrifiedDefenseAuraSource')) {
throw "Aunn split skill guardrail failed: AunnPetrifiedDefenseAura(331) must not be used as the Lv3 damage-bearing source skill."
}
if ($skillText.Contains('AunnKomainuGuardian')) {
throw "Aunn split skill guardrail failed: removed source skill AunnKomainuGuardian(343) must not exist in runtime skill code."
}
if ($skillText.Contains('target.AddOrOverrideSkill(SkillType.AunnHeroDamageBearer, map, aunn.Id)') -or
$skillText.Contains('IsAunnHeroDamageBearerSource')) {
throw "Aunn split skill guardrail failed: SkillType 342 is the Aunn Unique/source skill only; runtime beneficiary buff must use SkillType 344."
}
$aunnPetrifiedStateSkill = [regex]::Match(
$skillText,
'(?ms)^\s*public partial class AunnPetrifiedStateSkill\b.*?(?=^\s*public partial class AunnPetrifiedDefenseAuraSkill\b|\z)').Value
if (-not $aunnPetrifiedStateSkill) {
throw "Aunn petrified-state guardrail failed: missing AunnPetrifiedStateSkill."
}
if ($aunnPetrifiedStateSkill -match 'IsLevelSkill\s*=\s*true') {
throw "Aunn petrified-state guardrail failed: AunnPetrifiedState(296) is a boolean state and must not be a stack/level skill."
}
$requiredPetrifiedStateCode = @(
'public override bool HasLevel => false;',
'public override bool ShowSkillLevel => false;',
'public override bool ShowSkill => IsPetrified();',
'public void NormalizeDisplayState()',
'IsLevelSkill = false;',
'[MemoryPackOnDeserialized]'
)
foreach ($needle in $requiredPetrifiedStateCode) {
if (-not $aunnPetrifiedStateSkill.Contains($needle)) {
throw "Aunn petrified-state guardrail failed: missing '$needle'."
}
}
$runtimeDataText = Read-TextUtf8 $RuntimeDataFile
if (-not $runtimeDataText.Contains('NormalizeSkillDisplayState(skill)') -or
-not $runtimeDataText.Contains('skill is AunnPetrifiedStateSkill aunnPetrifiedState')) {
throw "Aunn petrified-state guardrail failed: RuntimeData must normalize old deserialized AunnPetrifiedStateSkill instances."
}
$sourceSkillDataText = Read-TextUtf8 $SkillDataAsset
$skill331 = [regex]::Match($sourceSkillDataText, '(?ms)^ - SkillType: 331\r?\n.*?(?=^ - SkillType: |\z)').Value
$skill332 = [regex]::Match($sourceSkillDataText, '(?ms)^ - SkillType: 332\r?\n.*?(?=^ - SkillType: |\z)').Value
$skill342 = [regex]::Match($sourceSkillDataText, '(?ms)^ - SkillType: 342\r?\n.*?(?=^ - SkillType: |\z)').Value
$skill344 = [regex]::Match($sourceSkillDataText, '(?ms)^ - SkillType: 344\r?\n.*?(?=^ - SkillType: |\z)').Value
if (-not $skill331 -or -not $skill332 -or -not $skill342 -or -not $skill344) {
throw "Source SkillDataAssets must contain SkillType 331, 332, 342, and 344."
}
if ($sourceSkillDataText -match '(?ms)^ - SkillType: 343\r?\n') {
throw "Source SkillDataAssets must not contain removed SkillType 343."
}
if ($skill331 -notmatch 'ShowOnUnitMono: 1') {
throw "Source SkillDataAssets SkillType 331 must be ShowOnUnitMono."
}
if ($skill331 -notmatch 'SkillIcon: \{fileID: 21300000, guid: 6f47452c0c494c579c2d939697037cd6, type: 3\}') {
throw "Source SkillDataAssets SkillType 331 must use Skill_AunnPetrifiedDefenseAura icon."
}
if ($skill331 -notmatch '\+1\\u9632\\u5FA1') {
throw "Source SkillDataAssets SkillType 331 description must describe only +1 defense."
}
if ($skill331 -match '\\u627F\\u53D7\\u4F24\\u5BB3') {
throw "Source SkillDataAssets SkillType 331 must not mention damage bearing; that belongs to SkillType 342."
}
if ($skill332 -notmatch 'SkillName: "\\u963F\\u543D\\u53C2\\u9053"') {
throw "Source SkillDataAssets SkillType 332 must be named Aunn Approach / 阿吽参道."
}
if ($skill332 -notmatch 'ShowOnUnitMono: 0') {
throw "Source SkillDataAssets SkillType 332 must not show on UnitMono."
}
if ($skill342 -notmatch '\\u72DB\\u72AC\\u8EAB\\u4EE3\\u5B88') {
throw "Source SkillDataAssets SkillType 342 must be named Komainu Substitute Guard."
}
if ($skill342 -notmatch 'ShowOnUnitMono: 0') {
throw "Source SkillDataAssets SkillType 342 must not be ShowOnUnitMono because it is the Aunn Unique/source skill."
}
if ($skill342 -match '\\u82F1\\u96C4') {
throw "Source SkillDataAssets SkillType 342 must not be described as hero-only."
}
if ($skill344 -notmatch '\\u72DB\\u72AC\\u8EAB\\u4EE3\\u5B88') {
throw "Source SkillDataAssets SkillType 344 must be named Komainu Substitute Guard."
}
if ($skill344 -notmatch 'SkillViewType: 4') {
throw "Source SkillDataAssets SkillType 344 must be a Positive buff."
}
if ($skill344 -notmatch 'ShowOnUnitMono: 1') {
throw "Source SkillDataAssets SkillType 344 must be ShowOnUnitMono because it is the runtime beneficiary buff."
}
if ($skill344 -match '\\u82F1\\u96C4') {
throw "Source SkillDataAssets SkillType 344 must not be described as hero-only."
}
$statusAreaText = Read-TextUtf8 $StatusAreaFile
$requiredStatusCode = @(
'showSkills.Remove(SkillType.AunnHeroDamageBearer)',
'HakureiNorwayHeroSkillUtil.HasAunnHeroDamageBearerBuffState(Main.MapData, unitData)',
'Table.Instance.SkillDataAssets.GetSkillInfo(SkillType.AunnHeroDamageBearerBuff'
)
foreach ($needle in $requiredStatusCode) {
if (-not $statusAreaText.Contains($needle)) {
throw "Aunn status display guardrail failed: missing '$needle'."
}
}
Assert-AunnUnitTypeRows "Source UnitTypeDataAssets" $UnitTypeAsset
if ($CheckExport) {
Assert-AunnUnitTypeRows "Export UnitTypeDataAssets" $ExportUnitTypeAsset
$exportSkillDataText = Read-TextUtf8 $ExportSkillDataAsset
$exportSkill342 = [regex]::Match($exportSkillDataText, '(?ms)^ - SkillType: 342\r?\n.*?(?=^ - SkillType: |\z)').Value
$exportSkill344 = [regex]::Match($exportSkillDataText, '(?ms)^ - SkillType: 344\r?\n.*?(?=^ - SkillType: |\z)').Value
if (-not $exportSkill342 -or $exportSkill342 -notmatch 'ShowOnUnitMono: 0') {
throw "Export SkillDataAssets SkillType 342 must not be ShowOnUnitMono."
}
if (-not $exportSkill344 -or $exportSkill344 -notmatch 'SkillViewType: 4' -or $exportSkill344 -notmatch 'ShowOnUnitMono: 1') {
throw "Export SkillDataAssets SkillType 344 must be a ShowOnUnitMono Positive buff."
}
$exportMultilingualText = Read-TextUtf8 $ExportMultilingualAsset
if ($exportMultilingualText -notmatch '(?ms)^ - ID: 21834\r?\n.*?\+1') {
throw "Export Multilingual.asset ID 21834 must describe +1 defense."
}
if ($exportMultilingualText -match '(?ms)^ - ID: 21834\r?\n.*?takes damage in its place') {
throw "Export Multilingual.asset ID 21834 must not mention damage bearing."
}
if ($exportMultilingualText -notmatch '(?ms)^ - ID: 21844\r?\n.*?Aunn Approach') {
throw "Export Multilingual.asset ID 21844 must be Aunn Approach."
}
if ($exportMultilingualText -notmatch '(?ms)^ - ID: 22000\r?\n.*?Komainu Substitute Guard') {
throw "Export Multilingual.asset ID 22000 must be Komainu Substitute Guard."
}
$exportMultilingual22001 = [regex]::Match($exportMultilingualText, '(?ms)^ - ID: 22001\r?\n.*?(?=^ - ID: |\z)').Value
if ($exportMultilingual22001 -notmatch 'non-Aunn allied units[\s\S]*?gain') {
throw "Export Multilingual.asset ID 22001 must describe SkillType 342 as the Aunn source skill that grants the buff."
}
if ($exportMultilingual22001 -match 'hero') {
throw "Export Multilingual.asset ID 22001 must not describe SkillType 342 as hero-only."
}
$exportMultilingual22028 = [regex]::Match($exportMultilingualText, '(?ms)^ - ID: 22028\r?\n.*?(?=^ - ID: |\z)').Value
if ($exportMultilingual22028 -notmatch 'Protected by a nearby Lv\.3\+') {
throw "Export Multilingual.asset ID 22028 must describe the SkillType 344 beneficiary buff."
}
if ($exportMultilingual22028 -match 'hero') {
throw "Export Multilingual.asset ID 22028 must not describe SkillType 344 as hero-only."
}
if ($exportMultilingualText -match '(?ms)^ - ID: 2202[5-7]\r?\n') {
throw "Export Multilingual.asset must not contain removed AunnKomainuGuardian localization rows 22025-22027."
}
}
Write-Host "Aunn split skill guardrail passed."

View File

@ -0,0 +1,80 @@
param(
[string]$SkillFile = "Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs",
[string]$UnitLogicFile = "Unity/Assets/Scripts/TH1_Logic/Unit/UnitLogic.cs"
)
$ErrorActionPreference = "Stop"
$repoRoot = git rev-parse --show-toplevel 2>$null
if (-not $repoRoot) {
throw "Not inside a git repository."
}
$repoRoot = [System.IO.Path]::GetFullPath($repoRoot.Trim())
$path = Join-Path $repoRoot $SkillFile
if (-not (Test-Path -LiteralPath $path)) {
throw "Aunn skill file not found: $path"
}
$unitLogicPath = Join-Path $repoRoot $UnitLogicFile
if (-not (Test-Path -LiteralPath $unitLogicPath)) {
throw "UnitLogic file not found: $unitLogicPath"
}
$text = Get-Content -LiteralPath $path -Raw -Encoding UTF8
$unitLogicText = Get-Content -LiteralPath $unitLogicPath -Raw -Encoding UTF8
$required = @(
'public static bool SyncAunnSharedHealthAfterDamage',
'public static bool SyncAunnSharedHealthAfterHeal',
'public static bool TryFindAunnSharedHealthPair',
'private static void SetAunnSharedHealthValue',
'private static void RemoveAunnSharedHealthSyncedDeath',
'map.OnAnyUnitDie(map, unit)',
'map.SetUnitDataDie(unit)',
'return info?.DamageBearer ?? info?.DamageTarget;',
'HakureiNorwayHeroSkillUtil.SyncAunnSharedHealthAfterDamage(mapData, damagedUnit',
'HakureiNorwayHeroSkillUtil.SyncAunnSharedHealthAfterHeal(mapData, self)'
)
foreach ($needle in $required) {
if (-not $text.Contains($needle)) {
throw "Aunn shared-health guardrail failed: missing '$needle'."
}
}
$aunnSharedHealthMatch = [regex]::Match($text, 'public partial class AunnSharedHealthSkill[\s\S]*?\n \}')
if (-not $aunnSharedHealthMatch.Success) {
throw "Aunn shared-health guardrail failed: missing AunnSharedHealthSkill."
}
$aunnSharedHealthText = $aunnSharedHealthMatch.Value
if ($aunnSharedHealthText -match 'SyncSharedDamage\(mapData,\s*info\.DamageTarget') {
throw "Aunn shared-health guardrail failed: damage sync must use DamageBearer ?? DamageTarget, not DamageTarget only."
}
if ($aunnSharedHealthText -match 'info\.DamageTarget\?\.Id\s*!=\s*self\.Id') {
throw "Aunn shared-health guardrail failed: OnUnitDamaged must not reject substitute damage where DamageTarget differs from the bearer."
}
if ($aunnSharedHealthText -match 'pair\.Health\s*=') {
throw "Aunn shared-health guardrail failed: pair health writes must go through the shared helper."
}
$requiredUnitLogic = @(
'HakureiNorwayHeroSkillUtil.SyncAunnSharedHealthAfterDamage(mapData, bearer ?? target, type);',
'HakureiNorwayHeroSkillUtil.SyncAunnSharedHealthAfterHeal(map, target);'
)
foreach ($needle in $requiredUnitLogic) {
if (-not $unitLogicText.Contains($needle)) {
throw "Aunn shared-health guardrail failed: UnitLogic missing invariant fallback '$needle'."
}
}
$healFallbackCount = [regex]::Matches(
$unitLogicText,
[regex]::Escape('HakureiNorwayHeroSkillUtil.SyncAunnSharedHealthAfterHeal(map, target);')).Count
if ($healFallbackCount -lt 2) {
throw "Aunn shared-health guardrail failed: both RecoverHealth and RecoverHealth_Legacy must keep the shared-health heal fallback."
}
Write-Host "Aunn shared-health guardrail passed."

View File

@ -0,0 +1,59 @@
param(
[string]$SkillFile = "Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs"
)
$ErrorActionPreference = "Stop"
$repoRoot = git rev-parse --show-toplevel 2>$null
if (-not $repoRoot) {
throw "Not inside a git repository."
}
$repoRoot = [System.IO.Path]::GetFullPath($repoRoot.Trim())
$path = Join-Path $repoRoot $SkillFile
if (-not (Test-Path -LiteralPath $path)) {
throw "Aunn skill file not found: $path"
}
$text = Get-Content -LiteralPath $path -Raw -Encoding UTF8
if ($text -match 'logic\?\.ActionId\?\.ActionType\s*!=\s*CommonActionType\.UnitMove' -or
$text -match 'logic\?\.ActionId\?\.ActionType\s*==\s*CommonActionType\.UnitMove') {
throw "Aunn twin auto-petrify must not be limited to UnitMove. It must trigger after any successful Aunn action before Lv4."
}
$required = @(
'TryGetAunnTwinActionResult',
'IsManualAunnPetrifyAction',
'MarkTwinSpawnedForCurrentAction',
'ShouldSkipTwinActionResultForSpawnAction(logic, param?.MapData)',
'IsAunnTwinSpawnInitializationAction',
'CommonActionType.TrainUnit',
'CommonActionType.UnitMove',
'UnitActionType.HeroUpgrade',
'if (currentActionIndex <= 0) return;',
'unit.AddOrOverrideSkill(skillType, map, originId)',
'twin.AddActionPoint(ActionPointType.Common)',
'if (IsAunnPetrified(body) && !HasAnyActionPoint(body)) return;',
'SetAunnPetrifiedAndClearActionPoint(param.MapData, waitingBody)'
)
foreach ($needle in $required) {
if (-not $text.Contains($needle)) {
throw "Aunn twin auto-petrify guardrail failed: missing '$needle'."
}
}
$spawnSkipMatch = [regex]::Match($text, 'private static bool IsAunnTwinSpawnInitializationAction[\s\S]*?\n \}')
if (-not $spawnSkipMatch.Success) {
throw "Aunn twin auto-petrify guardrail failed: missing spawn-initialization skip helper."
}
if ($spawnSkipMatch.Value -match 'UnitActionType\.Recover') {
throw "Aunn twin auto-petrify guardrail failed: Recover must never be treated as a twin-spawn initialization action."
}
$skipMethodMatch = [regex]::Match($text, 'private bool ShouldSkipTwinActionResultForSpawnAction[\s\S]*?\n \}')
if (-not $skipMethodMatch.Success -or $skipMethodMatch.Value -notmatch 'IsAunnTwinSpawnInitializationAction\(logic\)') {
throw "Aunn twin auto-petrify guardrail failed: one-action skip must be scoped to spawn initialization actions."
}
Write-Host "Aunn twin auto-petrify guardrail passed."

View File

@ -0,0 +1,240 @@
param(
[string]$AssetPath = "Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset",
[string]$ExportPath = "Unity/Assets/BundleResources/Export/UnitTypeDataAssets.asset",
[switch]$CheckExport
)
$ErrorActionPreference = "Stop"
$repoRoot = git rev-parse --show-toplevel 2>$null
if (-not $repoRoot) {
throw "Not inside a git repository."
}
$repoRoot = [System.IO.Path]::GetFullPath($repoRoot.Trim())
$prepareOfficerSkillHex = @(
"b6000000", # SkillType.OFFICER
"b3000000" # SkillType.JUNKEROFFICER
)
$cultureUpgradeChessTypes = @{
6 = "PawnWarrior"
7 = "PawnArcher"
8 = "PawnDefender"
9 = "PawnRider"
10 = "PawnKnight"
12 = "PawnCatapult"
13 = "PawnSword"
}
$explicitExemptions = @{
"20/0/0" = "KaguyaFrenchAnimalWarrior is an animal-resource conversion unit, not a normal Feudal Fief upgrade target."
}
function Resolve-RepoPath([string]$Path) {
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
}
function Get-UnitKey($Unit) {
return "$($Unit.UnitType)/$($Unit.GiantType)/$($Unit.UnitLevel)"
}
function Read-UnitTypeEntries([string]$Path) {
$fullPath = Resolve-RepoPath $Path
if (-not (Test-Path -LiteralPath $fullPath)) {
throw "UnitTypeDataAssets file not found: $fullPath"
}
$lines = Get-Content -LiteralPath $fullPath
$entries = @()
$current = $null
for ($i = 0; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
if ($line -match '^ - UnitType: (\d+)') {
if ($current) {
$entries += [pscustomobject]$current
}
$current = [ordered]@{
SourcePath = $Path
Line = $i + 1
UnitType = [int]$Matches[1]
GiantType = $null
UnitLevel = $null
ChessType = $null
Civ = $null
Force = $null
Name = ""
LandType = $null
Cost = $null
Skills = ""
}
continue
}
if (-not $current) {
continue
}
if ($line -match '^ GiantType: (\d+)') {
$current.GiantType = [int]$Matches[1]
continue
}
if ($line -match '^ UnitLevel: (\d+)') {
$current.UnitLevel = [int]$Matches[1]
continue
}
if ($line -match '^ ChessType: (\d+)') {
$current.ChessType = [int]$Matches[1]
continue
}
if ($line -match '^ Civ: (\d+)') {
$current.Civ = [int]$Matches[1]
continue
}
if ($line -match '^ Force: (\d+)') {
$current.Force = [int]$Matches[1]
continue
}
if ($line -match '^ Name: (.*)$') {
$current.Name = $Matches[1]
continue
}
if ($line -match '^ LandType: (\d+)') {
$current.LandType = [int]$Matches[1]
continue
}
if ($line -match '^ Cost: ([\d\.]+)') {
$current.Cost = [double]$Matches[1]
continue
}
if ($line -match '^ Skills: ?(.*)$') {
$current.Skills = $Matches[1].ToLowerInvariant()
continue
}
}
if ($current) {
$entries += [pscustomobject]$current
}
return $entries
}
function Test-HasPrepareOfficerSkill($Unit) {
foreach ($skillHex in $prepareOfficerSkillHex) {
if ($Unit.Skills -match $skillHex) {
return $true
}
}
return $false
}
function Test-IsExpectedCultureUpgradeTarget($Unit) {
if ($null -eq $Unit.GiantType -or $Unit.GiantType -ne 0) {
return $false
}
if ($null -eq $Unit.UnitLevel -or $Unit.UnitLevel -ne 0) {
return $false
}
if ($null -eq $Unit.Cost -or $Unit.Cost -le 0) {
return $false
}
if ($null -eq $Unit.LandType -or $Unit.LandType -ne 1) {
return $false
}
if ($null -eq $Unit.ChessType -or -not $cultureUpgradeChessTypes.ContainsKey([int]$Unit.ChessType)) {
return $false
}
if ($explicitExemptions.ContainsKey((Get-UnitKey $Unit))) {
return $false
}
return $true
}
function Get-MissingPrepareOfficerUnits($Units) {
foreach ($unit in $Units) {
if (-not (Test-IsExpectedCultureUpgradeTarget $unit)) {
continue
}
if (Test-HasPrepareOfficerSkill $unit) {
continue
}
[pscustomobject]@{
Path = $unit.SourcePath
Line = $unit.Line
UnitKey = Get-UnitKey $unit
Name = $unit.Name
ChessType = $cultureUpgradeChessTypes[[int]$unit.ChessType]
Skills = $unit.Skills
}
}
}
function Assert-NoMissingPrepareOfficer([string]$Label, $Units) {
$expected = @($Units | Where-Object { Test-IsExpectedCultureUpgradeTarget $_ })
$missing = @(Get-MissingPrepareOfficerUnits $Units)
if ($missing.Count -gt 0) {
Write-Host "$Label culture upgrade config errors:"
$missing | Format-Table -AutoSize
throw "$Label has $($missing.Count) ordinary culture-upgrade unit(s) missing prepare-officer skill."
}
Write-Host "$Label culture upgrade config OK: checked $($expected.Count) ordinary culture-upgrade unit(s)."
}
$sourceUnits = Read-UnitTypeEntries $AssetPath
Assert-NoMissingPrepareOfficer "Source" $sourceUnits
if ($CheckExport) {
$exportUnits = Read-UnitTypeEntries $ExportPath
Assert-NoMissingPrepareOfficer "Export" $exportUnits
$exportByKey = @{}
foreach ($unit in $exportUnits) {
$exportByKey[(Get-UnitKey $unit)] = $unit
}
$staleExportUnits = @()
foreach ($sourceUnit in $sourceUnits) {
if (-not (Test-IsExpectedCultureUpgradeTarget $sourceUnit)) {
continue
}
if (-not (Test-HasPrepareOfficerSkill $sourceUnit)) {
continue
}
$key = Get-UnitKey $sourceUnit
if (-not $exportByKey.ContainsKey($key)) {
$staleExportUnits += [pscustomobject]@{
UnitKey = $key
SourceLine = $sourceUnit.Line
ExportLine = ""
Problem = "missing in export"
}
continue
}
$exportUnit = $exportByKey[$key]
if (-not (Test-HasPrepareOfficerSkill $exportUnit)) {
$staleExportUnits += [pscustomobject]@{
UnitKey = $key
SourceLine = $sourceUnit.Line
ExportLine = $exportUnit.Line
Problem = "export lacks prepare-officer skill"
}
}
}
if ($staleExportUnits.Count -gt 0) {
Write-Host "Export culture upgrade sync errors:"
$staleExportUnits | Format-Table -AutoSize
throw "Export UnitTypeDataAssets is stale for $($staleExportUnits.Count) culture-upgrade unit(s). Refresh export through the normal Unity workflow."
}
Write-Host "Export culture upgrade config is in sync with source."
}

View File

@ -0,0 +1,144 @@
param()
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$failures = @()
function Add-Failure {
param([string]$Message)
$script:failures += $Message
}
function Assert-True {
param(
[bool]$Condition,
[string]$Message
)
if (-not $Condition) {
Add-Failure $Message
}
}
function Read-RepoText {
param([string]$RelativePath)
$path = Join-Path $repoRoot $RelativePath
Assert-True (Test-Path -LiteralPath $path) "Missing file: $RelativePath"
if (Test-Path -LiteralPath $path) {
return Get-Content -LiteralPath $path -Raw
}
return ""
}
function Count-Matches {
param(
[string]$Text,
[string]$Pattern
)
return ([regex]::Matches($Text, $Pattern)).Count
}
$skillAsset = Read-RepoText "Unity/Assets/BundleResources/DataAssets/SkillDataAssets.asset"
$unitAsset = Read-RepoText "Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset"
$actionAsset = Read-RepoText "Unity/Assets/BundleResources/DataAssets/ActionDataAssets.asset"
$gridAsset = Read-RepoText "Unity/Assets/BundleResources/DataAssets/GridAndResourceDataAssets.asset"
$skillEnum = Read-RepoText "Unity/Assets/Scripts/TH1_Logic/Skill/SkillBase.cs"
$unionFile = Read-RepoText "Unity/Assets/Scripts/TH1_Logic/Skill/Generate/SkillBase.MemoryPackUnion.g.cs"
$expectedKasenSkillHex = @{
1 = "02000000030000004d010000"
2 = "02000000030000004d01000052010000"
3 = "02000000030000004d0100005201000053010000"
4 = "02000000030000004d010000520100005301000055010000"
}
foreach ($level in 1..4) {
$rowPattern = "(?s)- UnitType: 14\s+GiantType: 23\s+UnitLevel: $level\b.*?(?=\r?\n - UnitType:|\z)"
$row = [regex]::Match($unitAsset, $rowPattern)
Assert-True $row.Success "Missing Kasen UnitType row for level $level."
if ($row.Success) {
$expected = $expectedKasenSkillHex[$level]
Assert-True ($row.Value -match [regex]::Escape("Skills: $expected")) "Kasen level $level has unexpected skill hex."
Assert-True ($row.Value -notmatch "25010000|27010000") "Kasen level $level still references old beast-guide/oni-form skill hex."
}
}
$expectedSkillTypes = @{
333 = "KasenBeastGuideReserve"
334 = "KasenBeastGuideReady"
335 = "KasenBeastGuideGrid"
336 = "KasenBeastGuideDefenseBuff"
337 = "KasenBeastGuideAttackBuff"
338 = "KasenBeastGuideLv2Display"
339 = "KasenBeastGuideLv3Display"
340 = "KasenBeastGuideBerserkBuff"
341 = "KasenPermanentBerserk"
}
foreach ($entry in $expectedSkillTypes.GetEnumerator()) {
Assert-True ($skillEnum -match "$($entry.Value)\s*=\s*$($entry.Key)\b") "Missing SkillType enum $($entry.Value) = $($entry.Key)."
$row = [regex]::Match($skillAsset, "(?s)- SkillType: $($entry.Key)\b.*?(?=\r?\n - SkillType:|\r?\n SkillViewTypeColorList:)")
Assert-True $row.Success "Missing SkillDataAssets row for SkillType $($entry.Key)."
if ($row.Success) {
Assert-True ($row.Value -match "ShowOnUnitMono:\s+0") "SkillType $($entry.Key) must not show on UnitMono."
}
}
$expectedUnions = @{
323 = "KasenBeastGuideReserveSkill"
324 = "KasenBeastGuideReadySkill"
325 = "KasenBeastGuideGridSkill"
326 = "KasenBeastGuideDefenseBuffSkill"
327 = "KasenBeastGuideAttackBuffSkill"
328 = "KasenBeastGuideLv2DisplaySkill"
329 = "KasenBeastGuideLv3DisplaySkill"
330 = "KasenBeastGuideBerserkBuffSkill"
331 = "KasenPermanentBerserkSkill"
}
foreach ($entry in $expectedUnions.GetEnumerator()) {
$className = $entry.Value
Assert-True ($unionFile -match "\[MemoryPackUnion\($($entry.Key), typeof\($className\)\)\]") "Missing MemoryPack union $($entry.Key) for $className."
$generatedPath = Join-Path $repoRoot "Unity/Assets/Scripts/TH1_Logic/Skill/Generate/$className.MemoryPackable.g.cs"
Assert-True (Test-Path -LiteralPath $generatedPath) "Missing MemoryPackable file for $className."
}
Assert-True ((Count-Matches $actionAsset "UnitActionType:\s+45") -eq 1) "UnitActionType 45 must appear exactly once."
Assert-True ((Count-Matches $actionAsset "UnitActionType:\s+46") -eq 1) "UnitActionType 46 must appear exactly once."
Assert-True ($actionAsset -match "(?s)UnitActionType:\s+45.*?ActionName: ""\\u56DE\\u6536\\u517D\\u5F15""") "UnitActionType 45 must be Recall Beast Guide."
Assert-True ($actionAsset -match "(?s)UnitActionType:\s+46.*?ActionName: ""\\u6E05\\u9664\\u517D\\u5F15""") "UnitActionType 46 must be Clear Beast Guide."
Assert-True ($gridAsset -match "GridSpType:\s+6") "GridAndResourceDataAssets must define GridSpType 6 sprite data."
$gridData = Read-RepoText "Unity/Assets/Scripts/TH1_Data/GridData.cs"
$gridObjectDataAssets = Read-RepoText "Unity/Assets/Scripts/TH1_DataAssetsScript/GridObjectDataAssets.cs"
$gridRenderer = Read-RepoText "Unity/Assets/Scripts/TH1_Renderer/GridRenderer.cs"
Assert-True ($gridData -match "KasenBeastGuide") "GridData must define KasenBeastGuide GridSpType."
Assert-True ($gridObjectDataAssets -match "GridSpType\.KasenBeastGuide") "GridObjectDataAssets must resolve the Kasen beast-guide sprite."
Assert-True ($gridRenderer -match "GridSpType\.KasenBeastGuide") "GridRenderer must keep the special tile layer visible for Kasen beast guide."
$scriptRoot = Join-Path $repoRoot "Unity/Assets/Scripts"
$badPatterns = @(
"UnitType\.KasenBeastGuideMarker",
"FindKasenGuide",
"TrySpawnKasenGuide",
"RecallKasenGuide",
"IsKasenGuideFor",
"CanSetBeastGuide"
)
foreach ($pattern in $badPatterns) {
$matches = Get-ChildItem -LiteralPath $scriptRoot -Recurse -Filter "*.cs" |
Select-String -Pattern $pattern
Assert-True (($matches | Measure-Object).Count -eq 0) "Forbidden old Kasen beast-guide pattern remains: $pattern"
}
if ($failures.Count -gt 0) {
Write-Host "Kasen beast guide implementation check failed:" -ForegroundColor Red
foreach ($failure in $failures) {
Write-Host " - $failure" -ForegroundColor Red
}
exit 1
}
Write-Host "Kasen beast guide implementation checks passed."

View File

@ -0,0 +1,378 @@
param(
[string]$AssetPath = "Unity/Assets/BundleResources/DataAssets/SkillDataAssets.asset",
[string]$ExportPath = "Unity/Assets/BundleResources/Export/SkillDataAssets.asset",
[string]$MultilingualPath = "Unity/Assets/BundleResources/Export/Multilingual.asset",
[string]$MultilingualTxtPath = "Tools/MultilingualTxt.txt",
[switch]$CheckExport
)
$ErrorActionPreference = "Stop"
$repoRoot = git rev-parse --show-toplevel 2>$null
if (-not $repoRoot) {
throw "Not inside a git repository."
}
$repoRoot = [System.IO.Path]::GetFullPath($repoRoot.Trim())
$requiredSkillTypes = @(
296, 297, 298, 299,
300, 301, 302, 303, 304, 305, 306,
307, 308, 309, 310,
313, 314, 315, 316, 317, 318, 319, 320, 321, 322,
327, 328, 329,
330, 331, 332
)
$leakTokens = @(
"NotShow:",
"ShowOnUnitMono:",
"SkillIcon:",
"HasShowList:",
"SkillShowList:",
"skillPriority:",
"ReserveOnCarry:",
"ReserveLeaveCarry:",
"ReserveGiantUpgrade:",
"ReserveCommonTransform:",
"- SkillType:",
"SkillViewType:"
)
$requiredSkillIconGuids = @{
296 = "a994762e362347569aaac57eda527bbd"
297 = "a557b1e216994dbc9aba71daa4b6cd7f"
298 = "d0c8d1956592444eb96e533d0b0ec728"
299 = "6b94561a0cb3413499cb61ee8788d4e0"
330 = "a994762e362347569aaac57eda527bbd"
331 = "6f47452c0c494c579c2d939697037cd6"
332 = "4e8a40912d3d450c92996e337cad1067"
}
$knownBadTextTokens = @(
'\u5E76\u975E\u9644\u8FD1',
'\u518D\u9644\u8FD1',
([char]0x947E).ToString(),
([char]0x934F).ToString(),
(([char]0x6D93).ToString() + ([char]0x5D86).ToString() + ([char]0x6D86).ToString()),
([char]0x9286).ToString(),
([char]0xFFFD).ToString(),
([char]0x00C3).ToString(),
([char]0x00C2).ToString()
)
$requiredMultilingualSnippetsById = @{
21828 = '\u5E76\u4E3A\u9644\u8FD1\u53CB\u65B9'
21829 = '\u5728\u9644\u8FD1\u751F\u6210'
21857 = '\u5728\u9644\u8FD1\u751F\u6210'
}
function Resolve-RepoPath([string]$Path) {
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
}
function Read-TextUtf8([string]$Path) {
$fullPath = Resolve-RepoPath $Path
if (-not (Test-Path -LiteralPath $fullPath)) {
throw "File not found: $fullPath"
}
return [System.IO.File]::ReadAllText($fullPath, [System.Text.Encoding]::UTF8)
}
function Get-SkillEntries([string]$Path) {
$fullPath = Resolve-RepoPath $Path
if (-not (Test-Path -LiteralPath $fullPath)) {
throw "SkillDataAssets file not found: $fullPath"
}
$lines = Get-Content -LiteralPath $fullPath -Encoding UTF8
$entries = @()
$current = $null
for ($i = 0; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
if ($line -match '^ - SkillType: (\d+)') {
if ($current) {
$entries += [pscustomobject]$current
}
$current = [ordered]@{
SkillType = [int]$Matches[1]
Line = $i + 1
Text = $line + "`n"
}
continue
}
if ($current) {
if ($line -match '^ SkillViewTypeColorList:') {
$entries += [pscustomobject]$current
$current = $null
break
}
$current.Text += $line + "`n"
}
}
if ($current) {
$entries += [pscustomobject]$current
}
return $entries
}
function Get-UnityMetaGuidSet {
if ($script:UnityMetaGuidSet) {
return $script:UnityMetaGuidSet
}
$assetsPath = Resolve-RepoPath "Unity/Assets"
$script:UnityMetaGuidSet = @{}
Get-ChildItem -LiteralPath $assetsPath -Filter "*.meta" -Recurse -File | ForEach-Object {
$metaText = [System.IO.File]::ReadAllText($_.FullName, [System.Text.Encoding]::UTF8)
if ($metaText -match '(?m)^guid: ([0-9a-f]{32})') {
$script:UnityMetaGuidSet[$Matches[1]] = $_.FullName
}
}
return $script:UnityMetaGuidSet
}
function Get-SkillFieldValue($Entry, [string]$FieldName) {
$escapedField = [regex]::Escape($FieldName)
if ($Entry.Text -match "(?m)^ ${escapedField}: (.*)$") {
return $Matches[1].Trim()
}
return $null
}
function Get-SkillIconInfo($Entry) {
$iconValue = Get-SkillFieldValue $Entry "SkillIcon"
if ([string]::IsNullOrWhiteSpace($iconValue)) {
return $null
}
if ($iconValue -match '^\{fileID: ([^,}]+)(?:, guid: ([0-9a-f]{32}), type: ([0-9]+))?\}$') {
return [pscustomobject]@{
FileID = $Matches[1]
Guid = if ($Matches.Count -ge 3) { $Matches[2] } else { "" }
Type = if ($Matches.Count -ge 4) { $Matches[3] } else { "" }
}
}
throw "SkillType $($Entry.SkillType) has malformed SkillIcon field near line $($Entry.Line): $iconValue"
}
function Assert-NoSerializedLeakInSkillText([string]$Label, $Entries) {
foreach ($entry in $Entries) {
$entryLines = @($entry.Text -split "`n")
for ($i = 0; $i -lt $entryLines.Count; $i++) {
$line = $entryLines[$i]
if ($line -notmatch '^ (SkillName|SkillDesc):[ \t]*(.*)$') {
continue
}
$fieldName = $Matches[1]
$fieldText = $Matches[2]
$j = $i + 1
while ($j -lt $entryLines.Count) {
$nextLine = $entryLines[$j]
if ($nextLine -match '^ [A-Za-z_][A-Za-z0-9_]*:' -or
$nextLine -match '^ - SkillType:' -or
$nextLine -match '^ SkillViewTypeColorList:') {
break
}
$fieldText += "`n" + $nextLine
$j++
}
foreach ($token in $leakTokens) {
if ($fieldText.Contains($token)) {
throw "$Label SkillType $($entry.SkillType) $fieldName contains serialized field token '$token' near line $($entry.Line)."
}
}
foreach ($token in $knownBadTextTokens) {
if ($fieldText.Contains($token)) {
throw "$Label SkillType $($entry.SkillType) $fieldName contains known-bad text token '$token' near line $($entry.Line)."
}
}
}
}
}
function Assert-SkillIcons([string]$Label, $Entries) {
$metaGuids = Get-UnityMetaGuidSet
foreach ($entry in $Entries) {
$iconInfo = Get-SkillIconInfo $entry
if ($null -eq $iconInfo) {
throw "$Label SkillType $($entry.SkillType) has no SkillIcon field near line $($entry.Line)."
}
if ($iconInfo.FileID -ne "0") {
if ([string]::IsNullOrWhiteSpace($iconInfo.Guid)) {
throw "$Label SkillType $($entry.SkillType) has a non-empty SkillIcon fileID but no guid near line $($entry.Line)."
}
if (-not $metaGuids.ContainsKey($iconInfo.Guid)) {
throw "$Label SkillType $($entry.SkillType) SkillIcon guid $($iconInfo.Guid) has no .meta file under Unity/Assets."
}
}
$showOnUnitMono = Get-SkillFieldValue $entry "ShowOnUnitMono"
if ($showOnUnitMono -eq "1" -and ($iconInfo.FileID -eq "0" -or [string]::IsNullOrWhiteSpace($iconInfo.Guid))) {
throw "$Label SkillType $($entry.SkillType) is ShowOnUnitMono but has an empty SkillIcon near line $($entry.Line)."
}
if ($requiredSkillIconGuids.ContainsKey($entry.SkillType)) {
$expectedGuid = $requiredSkillIconGuids[$entry.SkillType]
if ($iconInfo.Guid -ne $expectedGuid) {
throw "$Label SkillType $($entry.SkillType) SkillIcon guid is $($iconInfo.Guid); expected $expectedGuid."
}
}
}
}
function Assert-SkillIconSync($SourceEntries, $ExportEntries) {
$sourceByType = @{}
foreach ($entry in $SourceEntries) {
$sourceByType[$entry.SkillType] = $entry
}
foreach ($exportEntry in $ExportEntries) {
if (-not $sourceByType.ContainsKey($exportEntry.SkillType)) {
continue
}
$sourceIcon = Get-SkillIconInfo $sourceByType[$exportEntry.SkillType]
$exportIcon = Get-SkillIconInfo $exportEntry
if ($sourceIcon.FileID -ne $exportIcon.FileID -or $sourceIcon.Guid -ne $exportIcon.Guid) {
throw "Export SkillType $($exportEntry.SkillType) SkillIcon does not match source. source=$($sourceIcon.FileID)/$($sourceIcon.Guid); export=$($exportIcon.FileID)/$($exportIcon.Guid)."
}
}
}
function Assert-SkillDataAsset([string]$Label, [string]$Path) {
$entries = @(Get-SkillEntries $Path)
if ($entries.Count -lt 290) {
throw "$Label SkillDataAssets has only $($entries.Count) skill rows; expected at least 290."
}
$counts = @{}
foreach ($entry in $entries) {
if (-not $counts.ContainsKey($entry.SkillType)) {
$counts[$entry.SkillType] = 0
}
$counts[$entry.SkillType]++
}
$duplicates = @($counts.Keys | Where-Object { $counts[$_] -gt 1 } | Sort-Object)
if ($duplicates.Count -gt 0) {
throw "$Label SkillDataAssets has duplicate SkillType rows: $($duplicates -join ', ')."
}
$missing = @($requiredSkillTypes | Where-Object { -not $counts.ContainsKey($_) })
if ($missing.Count -gt 0) {
throw "$Label SkillDataAssets is missing required SkillType rows: $($missing -join ', ')."
}
Assert-NoSerializedLeakInSkillText $Label $entries
Assert-SkillIcons $Label $entries
Write-Host "$Label SkillDataAssets OK: checked $($entries.Count) skill rows."
return $entries
}
function Get-MultilingualBlocksById([string]$Path) {
$text = Read-TextUtf8 $Path
$blocksById = @{}
$matches = [regex]::Matches($text, '(?ms)^ - ID: (\d+)\r?\n.*?(?=^ - ID: |\z)')
foreach ($match in $matches) {
$blocksById[[int]$match.Groups[1].Value] = $match.Value
}
return $blocksById
}
function Assert-ExportMultilingualReferences($ExportEntries, [string]$MultilingualAssetPath) {
$blocksById = Get-MultilingualBlocksById $MultilingualAssetPath
foreach ($entry in $ExportEntries) {
foreach ($fieldName in @("SkillName", "SkillDesc")) {
$fieldValue = Get-SkillFieldValue $entry $fieldName
if ($fieldValue -notmatch '^\d+$') {
continue
}
$id = [int]$fieldValue
if (-not $blocksById.ContainsKey($id)) {
throw "Export SkillType $($entry.SkillType) $fieldName references missing multilingual ID $id."
}
$block = $blocksById[$id]
if ($block -match '(?m)^ ZH:\s*$') {
throw "Export SkillType $($entry.SkillType) $fieldName references multilingual ID $id with empty ZH."
}
}
}
foreach ($id in $requiredMultilingualSnippetsById.Keys) {
if (-not $blocksById.ContainsKey($id)) {
throw "Export Multilingual.asset is missing required ID $id."
}
$snippet = $requiredMultilingualSnippetsById[$id]
if (-not $blocksById[$id].Contains($snippet)) {
throw "Export Multilingual.asset ID $id does not contain required snippet $snippet."
}
}
}
function Assert-NoSerializedLeakInTextFile([string]$Label, [string]$Path) {
$text = Read-TextUtf8 $Path
foreach ($token in $leakTokens) {
if ($text.Contains($token)) {
throw "$Label contains serialized field token '$token': $Path"
}
}
foreach ($token in $knownBadTextTokens) {
if ($text.Contains($token)) {
throw "$Label contains known-bad text token '$token': $Path"
}
}
if ($text -match '(^|!@#\$%)21861%\$#@!') {
throw "$Label still contains polluted multilingual ID 21861: $Path"
}
Write-Host "$Label OK: no serialized field leakage detected."
}
$sourceEntries = Assert-SkillDataAsset "Source" $AssetPath
if ($CheckExport) {
$exportEntries = Assert-SkillDataAsset "Export" $ExportPath
$sourceSet = @{}
foreach ($entry in $sourceEntries) {
$sourceSet[$entry.SkillType] = $true
}
$exportSet = @{}
foreach ($entry in $exportEntries) {
$exportSet[$entry.SkillType] = $true
}
$missingInExport = @($sourceSet.Keys | Where-Object { -not $exportSet.ContainsKey($_) } | Sort-Object)
if ($missingInExport.Count -gt 0) {
throw "Export SkillDataAssets is stale; missing source SkillType rows: $($missingInExport -join ', ')."
}
Assert-SkillIconSync $sourceEntries $exportEntries
Assert-ExportMultilingualReferences $exportEntries $MultilingualPath
Assert-NoSerializedLeakInTextFile "Export Multilingual.asset" $MultilingualPath
Assert-NoSerializedLeakInTextFile "Tools MultilingualTxt.txt" $MultilingualTxtPath
}

View File

@ -0,0 +1,111 @@
param(
[string]$SkillFile = "Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs",
[string]$ActionFile = "Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs",
[string]$FragmentFile = "Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentSuikaFallingSplash.cs",
[string]$FragmentDataFile = "Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentData.cs",
[string]$AttackGroundFragmentFile = "Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentAttackGround.cs",
[string]$UnitLogicFile = "Unity/Assets/Scripts/TH1_Logic/Unit/UnitLogic.cs",
[string]$FragmentManagerFile = "Unity/Assets/Scripts/TH1_Anim/FragmentManager.cs",
[string]$AtomFile = "Unity/Assets/Scripts/TH1_Anim/UnitAtomAnim/UnitAtomAnim.cs",
[string]$AtomDataFile = "Unity/Assets/Scripts/TH1_Anim/UnitAtomAnim/UnitAtomAnimData.cs",
[string]$AtomMoveFile = "Unity/Assets/Scripts/TH1_Anim/UnitAtomAnim/UnitAtomAnimMove.cs"
)
$ErrorActionPreference = "Stop"
$repoRoot = git rev-parse --show-toplevel 2>$null
if (-not $repoRoot) {
throw "Not inside a git repository."
}
$repoRoot = [System.IO.Path]::GetFullPath($repoRoot.Trim())
function Read-RepoFile([string]$relativePath) {
$fullPath = Join-Path $repoRoot $relativePath
if (-not (Test-Path -LiteralPath $fullPath)) {
throw "Required file not found: $fullPath"
}
return Get-Content -LiteralPath $fullPath -Raw -Encoding UTF8
}
$skillText = Read-RepoFile $SkillFile
$actionText = Read-RepoFile $ActionFile
$fragmentText = Read-RepoFile $FragmentFile
$fragmentDataText = Read-RepoFile $FragmentDataFile
$attackGroundFragmentText = Read-RepoFile $AttackGroundFragmentFile
$unitLogicText = Read-RepoFile $UnitLogicFile
$fragmentManagerText = Read-RepoFile $FragmentManagerFile
$atomText = Read-RepoFile $AtomFile
$atomDataText = Read-RepoFile $AtomDataFile
$atomMoveText = Read-RepoFile $AtomMoveFile
function Assert-Contains([string]$text, [string]$needle, [string]$label) {
if (-not $text.Contains($needle)) {
throw "Suika falling splash animation guardrail failed: $label missing '$needle'."
}
}
Assert-Contains $atomText 'ParabolaMove' 'atom animation type'
Assert-Contains $atomText 'UnitAtomAnimType.ParabolaMove => new UnitAtomAnimParabolaMove' 'atom animation factory'
Assert-Contains $atomDataText 'UnitAtomAnimParabolaMoveData' 'atom animation data'
Assert-Contains $atomDataText 'float duration = 1.1f' 'parabola default duration'
Assert-Contains $atomDataText 'float height = 18f' 'parabola default height'
Assert-Contains $atomMoveText 'class UnitAtomAnimParabolaMove' 'parabola atom animation'
Assert-Contains $fragmentManagerText 'FragmentType.SuikaFallingSplash => new FragmentSuikaFallingSplash' 'fragment factory'
Assert-Contains $fragmentText 'class FragmentSuikaFallingSplash' 'Suika fragment'
Assert-Contains $fragmentText 'private const float JumpDuration = 1.1f;' 'Suika fragment jump duration'
Assert-Contains $fragmentText 'private const float JumpHeight = 18f;' 'Suika fragment jump height'
Assert-Contains $fragmentText 'SplashImpactPause' 'Suika fragment post-splash pause'
Assert-Contains $fragmentText 'UnitAtomAnimType.ParabolaMove' 'Suika fragment launch arc'
Assert-Contains $fragmentText 'AnimPhase.AttackImpact + 20' 'post-impact final move phase'
Assert-Contains $fragmentText 'UnitAtomAnimType.Move' 'post-impact final move'
Assert-Contains $fragmentText 'RefreshFinalState' 'final refresh'
Assert-Contains $fragmentText 'RefreshLandingSight' 'landing sight refresh'
Assert-Contains $fragmentText 'foreach (var grid in Data.SightRefreshGrids)' 'new sight grid iteration'
Assert-Contains $fragmentText 'gridRenderer?.InstantUpdateGrid(true)' 'new sight grid force refresh'
Assert-Contains $fragmentDataText 'public List<GridData> SightRefreshGrids;' 'fragment sight refresh data'
Assert-Contains $unitLogicText 'public List<GridData> SuikaFallingSplashSightRefreshGrids;' 'attack info sight refresh data'
Assert-Contains $actionText 'sightRefreshGrids: attackInfo.SuikaFallingSplashSightRefreshGrids' 'attack fragment sight refresh handoff'
Assert-Contains $actionText 'List<GridData> suikaGroundSightRefreshGrids = null;' 'ground jump sight refresh cache'
Assert-Contains $actionText 'sightRefreshGrids: suikaGroundSightRefreshGrids' 'ground jump fragment sight refresh handoff'
Assert-Contains $actionText 'FragmentType.SuikaFallingSplash, suikaData' 'ground self-jump fragment'
Assert-Contains $actionText 'TryCreateSuikaFallingSplashFragment' 'attack action fragment switch'
Assert-Contains $actionText 'FragmentType.SuikaFallingSplash' 'attack action fragment type'
Assert-Contains $actionText 'visualCollector?.FlushTo(suikaFallingSplashFragment)' 'splash damage visual collection'
Assert-Contains $skillText 'TryRepositionUnitWithoutMoveSideEffects(map, suika, target)' 'ground self-jump data-only reposition'
Assert-Contains $skillText 'CollectSuikaFallingSplashNewSightGrids' 'Suika sight refresh collector'
Assert-Contains $skillText 'attackInfo.IsSuikaFallingSplash = true;' 'skill attack marker'
Assert-Contains $skillText 'attackInfo.SuikaFallingSplashFinalGrid = landingGrid;' 'skill final grid marker'
Assert-Contains $skillText 'ExecuteSuikaFallingSplashDamage(map, suika, targetGrid, AnimPhase.AttackImpact + 10)' 'post-impact splash visual phase'
if ($attackGroundFragmentText -match 'SkillType\.SuikaFallingSplash\s*=>\s*ProjectileType\.Bomb') {
throw "Suika falling splash animation guardrail failed: FragmentAttackGround must not map SuikaFallingSplash to Bomb projectile."
}
$method = [regex]::Match($skillText, '(?ms)public static bool TryExecuteSuikaFlyAfterAttack\(MapData map, AttackInfo attackInfo\).*?^\s*private static void ExecuteSuikaFallingSplashDamage')
if (-not $method.Success) {
throw "Suika falling splash animation guardrail failed: cannot locate TryExecuteSuikaFlyAfterAttack body."
}
$body = $method.Value
if ($body -notmatch 'ExecuteSuikaFallingSplashDamage\(map, suika, targetGrid, AnimPhase\.AttackImpact \+ 10\);[\s\S]*?sightRefreshGrids = CollectSuikaFallingSplashNewSightGrids\(map, suika, attackInfo\.OriginPlayer,[\s\S]*?landingGrid\);[\s\S]*?TryRepositionUnitWithoutMoveSideEffects\(map, suika, landingGrid\)[\s\S]*?attackInfo\.IsSuikaFallingSplash = true;[\s\S]*?attackInfo\.SuikaFallingSplashFinalGrid = landingGrid;[\s\S]*?attackInfo\.SuikaFallingSplashSightRefreshGrids = sightRefreshGrids;[\s\S]*?UpdateSightByPath') {
throw "Suika falling splash animation guardrail failed: attack-after-splash must settle damage, reposition data, cache new sight grids, then update sight."
}
$groundMethod = [regex]::Match($actionText, '(?ms)public class UnitAttackGroundAction.*?protected override bool Execute\(CommonActionParams actionParams\).*?^\s*public override bool CheckCan')
if (-not $groundMethod.Success) {
throw "Suika falling splash animation guardrail failed: cannot locate UnitAttackGroundAction execute body."
}
if ($groundMethod.Value -notmatch 'suikaGroundSightRefreshGrids = HakureiNorwayHeroSkillUtil\.CollectSuikaFallingSplashNewSightGrids[\s\S]*?unit1\.AttackGroundExecute\(actionParams\.MapData, targetGrid, out animSkillData\)[\s\S]*?sightRefreshGrids: suikaGroundSightRefreshGrids') {
throw "Suika falling splash animation guardrail failed: ground self-jump must cache sight grids before AttackGroundExecute updates sight, then pass them into the Suika fragment."
}
$fragmentMethod = [regex]::Match($fragmentText, '(?ms)public class FragmentSuikaFallingSplash.*?^\s*public override void OnUpdate')
if (-not $fragmentMethod.Success) {
throw "Suika falling splash animation guardrail failed: cannot locate FragmentSuikaFallingSplash body."
}
if ($fragmentMethod.Value -notmatch 'UnitAtomAnimParabolaMoveData[\s\S]*?JumpHeight[\s\S]*?JumpDuration[\s\S]*?PlayTargetAttackImpact\(\);[\s\S]*?PlayLandingImpact\(\);[\s\S]*?AnimPhase\.AttackImpact \+ 15[\s\S]*?SplashImpactPause[\s\S]*?AnimPhase\.AttackImpact \+ 20[\s\S]*?UnitAtomAnimType\.Move[\s\S]*?AnimPhase\.Settle') {
throw "Suika falling splash animation guardrail failed: fragment must play a high, slow parabola, impact/splash pause, optional final move, then settle refresh."
}
Write-Host "Suika falling splash animation guardrail passed."

View File

@ -0,0 +1,67 @@
param(
[string[]]$ScanPaths = @(
"Unity/Assets/BundleResources/DataAssets",
"Unity/Assets/BundleResources/Export"
)
)
$ErrorActionPreference = "Stop"
$repoRoot = git rev-parse --show-toplevel 2>$null
if (-not $repoRoot) {
throw "Not inside a git repository."
}
$repoRoot = [System.IO.Path]::GetFullPath($repoRoot.Trim())
function Resolve-RepoPath([string]$Path) {
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
return [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path))
}
$metaGuids = @{}
$assetsPath = Resolve-RepoPath "Unity/Assets"
Get-ChildItem -LiteralPath $assetsPath -Filter "*.meta" -Recurse -File | ForEach-Object {
$metaText = [System.IO.File]::ReadAllText($_.FullName, [System.Text.Encoding]::UTF8)
if ($metaText -match '(?m)^guid: ([0-9a-f]{32})') {
$metaGuids[$Matches[1]] = $_.FullName
}
}
$issues = New-Object System.Collections.Generic.List[string]
$assetRegex = [regex]'\{fileID: ([^,}]+), guid: ([0-9a-f]{32}), type: ([0-9]+)\}'
foreach ($scanPath in $ScanPaths) {
$fullScanPath = Resolve-RepoPath $scanPath
if (-not (Test-Path -LiteralPath $fullScanPath)) {
throw "Scan path not found: $fullScanPath"
}
Get-ChildItem -LiteralPath $fullScanPath -Filter "*.asset" -Recurse -File | ForEach-Object {
$relativePath = $_.FullName
if ($relativePath.StartsWith($repoRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
$relativePath = $relativePath.Substring($repoRoot.Length).TrimStart('\', '/')
}
$lines = [System.IO.File]::ReadAllLines($_.FullName, [System.Text.Encoding]::UTF8)
for ($i = 0; $i -lt $lines.Count; $i++) {
foreach ($match in $assetRegex.Matches($lines[$i])) {
$fileId = $match.Groups[1].Value
$guid = $match.Groups[2].Value
if ($fileId -eq "0") {
continue
}
if (-not $metaGuids.ContainsKey($guid)) {
$issues.Add("${relativePath}:$($i + 1) references missing Unity guid $guid")
}
}
}
}
}
if ($issues.Count -gt 0) {
throw "Missing Unity asset references:`n$($issues -join "`n")"
}
Write-Host "Unity asset references OK: scanned $($ScanPaths -join ', ')."

View File

@ -2083,13 +2083,13 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
raw_list = data.get('SkillInfoList', [])
if not isinstance(raw_list, list):
raw_list = []
if asset_index < 0 or asset_index >= len(raw_list):
raise ValueError('assetIndex out of range')
asset_index = self._resolve_form_helper_skill_index(
exporter,
raw_list,
asset_index,
expected_skill_type,
)
current_item = raw_list[asset_index]
current_skill_type = exporter.safe_int(current_item.get('SkillType'))
if expected_skill_type is not None and current_skill_type != expected_skill_type:
raise ValueError(f'skillType mismatch: expected {expected_skill_type}, current {current_skill_type}')
with open(SKILL_DATA_ASSET, 'r', encoding='utf-8', newline='') as f:
lines = f.readlines()
@ -2149,6 +2149,39 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
'source': self._project_relpath(SKILL_DATA_ASSET),
}
def _resolve_form_helper_skill_index(self, exporter, raw_list, asset_index, expected_skill_type):
if asset_index < 0:
raise ValueError('assetIndex out of range')
if asset_index < len(raw_list):
current_item = raw_list[asset_index]
current_skill_type = exporter.safe_int(current_item.get('SkillType'))
if expected_skill_type is None or current_skill_type == expected_skill_type:
return asset_index
matches = [
index
for index, item in enumerate(raw_list)
if exporter.safe_int(item.get('SkillType')) == expected_skill_type
]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise ValueError(f'duplicate SkillType rows: {expected_skill_type}')
raise ValueError(f'skillType mismatch: expected {expected_skill_type}, current {current_skill_type}')
if expected_skill_type is not None:
matches = [
index
for index, item in enumerate(raw_list)
if exporter.safe_int(item.get('SkillType')) == expected_skill_type
]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
raise ValueError(f'duplicate SkillType rows: {expected_skill_type}')
raise ValueError('assetIndex out of range')
def _current_skill_top_value(self, exporter, item, key, kind):
if key == 'SkillName':
return self._decode_skill_string(exporter, item.get('SkillName', ''))

View File

@ -40,7 +40,7 @@ def convert_file():
tools_dir = Path(__file__).resolve().parent
txt_path = tools_dir / "MultilingualTxt.txt"
excel_path = tools_dir / "Multilingual.xlsx"
with open(txt_path, 'r', encoding='utf-8') as f:
with open(txt_path, 'r', encoding='utf-8-sig') as f:
content = f.read()
records = parse_special_format(content)

View File

@ -32,8 +32,9 @@ function Test-PathLike([string]$Path, [string[]]$Patterns) {
return $false
}
$protectedExportPatterns = @(
$advisoryExportPatterns = @(
'Unity/Assets/Resources/Export/*',
'Unity/Assets/BundleResources/Export/*',
'Tools/Multilingual.xlsx',
'Tools/MultilingualTxt.txt'
)
@ -91,7 +92,7 @@ if ($StagedOnly) {
git diff --stat
}
$protected = @($paths | Where-Object { Test-PathLike $_ $protectedExportPatterns } | Sort-Object -Unique)
$advisoryGenerated = @($paths | Where-Object { Test-PathLike $_ $advisoryExportPatterns } | Sort-Object -Unique)
$artifacts = @($paths | Where-Object { Test-PathLike $_ $buildArtifactPatterns } | Sort-Object -Unique)
$graphifyLarge = @($paths | Where-Object { Test-PathLike $_ $largeGraphifyPatterns } | Sort-Object -Unique)
@ -100,27 +101,163 @@ $touchedAot = @($paths | Where-Object { $_ -like 'Unity/Assets/Scripts/*AOT*' -o
$touchedEditor = @($paths | Where-Object { $_ -like 'Unity/Assets/Scripts/*Editor*' -or $_ -like 'Unity/Assets/Editor/*' })
$touchedConfig = @($paths | Where-Object { $_ -like 'ExcelExport/*' -or $_ -like 'Unity/Assets/Scripts/TH1_Config/*' })
$touchedTools = @($paths | Where-Object { $_ -like 'Tools/*' })
$touchedCultureUpgradeConfig = @($paths | Where-Object {
$_ -eq 'Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset' -or
$_ -eq 'Unity/Assets/BundleResources/Export/UnitTypeDataAssets.asset' -or
$_ -eq 'Tools/CheckCultureUpgradeUnitConfig.ps1'
})
$touchedAunnTwinSkill = @($paths | Where-Object {
$_ -eq 'Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs' -or
$_ -eq 'Tools/CheckAunnTwinAutoPetrify.ps1'
})
$touchedAunnPetrifiedDefenseAura = @($paths | Where-Object {
$_ -eq 'Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs' -or
$_ -eq 'Unity/Assets/BundleResources/DataAssets/UnitTypeDataAssets.asset' -or
$_ -eq 'Unity/Assets/BundleResources/Export/UnitTypeDataAssets.asset' -or
$_ -eq 'Unity/Assets/BundleResources/DataAssets/SkillDataAssets.asset' -or
$_ -eq 'Unity/Assets/BundleResources/Export/Multilingual.asset' -or
$_ -eq 'Tools/CheckAunnPetrifiedDefenseAura.ps1'
})
$touchedSuikaFallingSplashAnimation = @($paths | Where-Object {
$_ -eq 'Unity/Assets/Scripts/TH1_Logic/Skill/AllSkill/HakureiNorwayHeroSkill.cs' -or
$_ -eq 'Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs' -or
$_ -eq 'Unity/Assets/Scripts/TH1_Logic/Unit/UnitLogic.cs' -or
$_ -eq 'Unity/Assets/Scripts/TH1_Anim/FragmentManager.cs' -or
$_ -eq 'Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentData.cs' -or
$_ -eq 'Unity/Assets/Scripts/TH1_Anim/Fragments/FragmentSkillEffect.cs' -or
$_ -eq 'Unity/Assets/Scripts/TH1_Anim/UnitAtomAnim/UnitAtomAnim.cs' -or
$_ -eq 'Unity/Assets/Scripts/TH1_Anim/UnitAtomAnim/UnitAtomAnimData.cs' -or
$_ -eq 'Unity/Assets/Scripts/TH1_Anim/UnitAtomAnim/UnitAtomAnimMove.cs' -or
$_ -eq 'Tools/CheckSuikaFallingSplashAnimation.ps1'
})
$touchedSkillDataAssets = @($paths | Where-Object {
$_ -eq 'Unity/Assets/BundleResources/DataAssets/SkillDataAssets.asset' -or
$_ -eq 'Unity/Assets/BundleResources/Export/SkillDataAssets.asset' -or
$_ -eq 'Unity/Assets/BundleResources/Export/Multilingual.asset' -or
$_ -eq 'Tools/MultilingualTxt.txt' -or
$_ -eq 'Tools/CheckSkillDataAssetsIntegrity.ps1'
})
$touchedUnityDataAssetReferences = @($paths | Where-Object {
$_ -like 'Unity/Assets/BundleResources/DataAssets/*.asset' -or
$_ -like 'Unity/Assets/BundleResources/Export/*.asset' -or
$_ -eq 'Tools/CheckUnityAssetReferenceIntegrity.ps1'
})
Write-Host ""
Write-Host "Guardrails:"
$hasGuardrailIssue = $false
if ($protected.Count -gt 0) {
$hasGuardrailIssue = $true
Write-Host " WARNING: protected export/localization files changed:"
$protected | ForEach-Object { Write-Host " $_" }
$hasAdvisoryIssue = $false
$hasBlockingIssue = $false
if ($advisoryGenerated.Count -gt 0) {
$hasAdvisoryIssue = $true
Write-Host " NOTICE: generated/export/localization files changed:"
$advisoryGenerated | ForEach-Object { Write-Host " $_" }
Write-Host " These are allowed in normal TH1 content iteration; confirm they are intentional."
}
if ($artifacts.Count -gt 0) {
$hasGuardrailIssue = $true
$hasBlockingIssue = $true
Write-Host " WARNING: build/package artifacts changed:"
$artifacts | ForEach-Object { Write-Host " $_" }
}
if ($graphifyLarge.Count -gt 0) {
$hasGuardrailIssue = $true
$hasBlockingIssue = $true
Write-Host " WARNING: large graphify artifacts changed:"
$graphifyLarge | ForEach-Object { Write-Host " $_" }
}
if (-not $hasGuardrailIssue) {
Write-Host " no protected generated/export paths detected"
if (-not $hasAdvisoryIssue -and -not $hasBlockingIssue) {
Write-Host " no generated/export or artifact paths detected"
}
if ($touchedCultureUpgradeConfig.Count -gt 0) {
Write-Host ""
Write-Host "Culture upgrade config check:"
$cultureCheckPath = Join-Path $repoRoot "Tools/CheckCultureUpgradeUnitConfig.ps1"
try {
if ($paths -contains 'Unity/Assets/BundleResources/Export/UnitTypeDataAssets.asset') {
& $cultureCheckPath -CheckExport
} else {
& $cultureCheckPath
}
} catch {
$hasBlockingIssue = $true
Write-Host " WARNING: culture upgrade config guardrail failed:"
Write-Host " $($_.Exception.Message)"
}
}
if ($touchedAunnTwinSkill.Count -gt 0) {
Write-Host ""
Write-Host "Aunn twin auto-petrify check:"
$aunnCheckPath = Join-Path $repoRoot "Tools/CheckAunnTwinAutoPetrify.ps1"
try {
& $aunnCheckPath
} catch {
$hasBlockingIssue = $true
Write-Host " WARNING: Aunn twin auto-petrify guardrail failed:"
Write-Host " $($_.Exception.Message)"
}
}
if ($touchedAunnPetrifiedDefenseAura.Count -gt 0) {
Write-Host ""
Write-Host "Aunn Lv3 petrified-defense aura check:"
$aunnAuraCheckPath = Join-Path $repoRoot "Tools/CheckAunnPetrifiedDefenseAura.ps1"
try {
if ($paths -contains 'Unity/Assets/BundleResources/Export/UnitTypeDataAssets.asset' -or
$paths -contains 'Unity/Assets/BundleResources/Export/Multilingual.asset') {
& $aunnAuraCheckPath -CheckExport
} else {
& $aunnAuraCheckPath
}
} catch {
$hasBlockingIssue = $true
Write-Host " WARNING: Aunn Lv3 petrified-defense aura guardrail failed:"
Write-Host " $($_.Exception.Message)"
}
}
if ($touchedSuikaFallingSplashAnimation.Count -gt 0) {
Write-Host ""
Write-Host "Suika falling splash animation check:"
$suikaFallingSplashCheckPath = Join-Path $repoRoot "Tools/CheckSuikaFallingSplashAnimation.ps1"
try {
& $suikaFallingSplashCheckPath
} catch {
$hasBlockingIssue = $true
Write-Host " WARNING: Suika falling splash animation guardrail failed:"
Write-Host " $($_.Exception.Message)"
}
}
if ($touchedSkillDataAssets.Count -gt 0) {
Write-Host ""
Write-Host "SkillDataAssets integrity check:"
$skillDataAssetsCheckPath = Join-Path $repoRoot "Tools/CheckSkillDataAssetsIntegrity.ps1"
try {
if ($paths -contains 'Unity/Assets/BundleResources/Export/SkillDataAssets.asset' -or
$paths -contains 'Unity/Assets/BundleResources/Export/Multilingual.asset' -or
$paths -contains 'Tools/MultilingualTxt.txt') {
& $skillDataAssetsCheckPath -CheckExport
} else {
& $skillDataAssetsCheckPath
}
} catch {
$hasBlockingIssue = $true
Write-Host " WARNING: SkillDataAssets integrity guardrail failed:"
Write-Host " $($_.Exception.Message)"
}
}
if ($touchedUnityDataAssetReferences.Count -gt 0) {
Write-Host ""
Write-Host "Unity asset reference check:"
$assetReferenceCheckPath = Join-Path $repoRoot "Tools/CheckUnityAssetReferenceIntegrity.ps1"
try {
& $assetReferenceCheckPath
} catch {
$hasBlockingIssue = $true
Write-Host " WARNING: Unity asset reference guardrail failed:"
Write-Host " $($_.Exception.Message)"
}
}
Write-Host ""
@ -142,10 +279,31 @@ if ($touchedConfig.Count -gt 0) {
if ($touchedTools.Count -gt 0) {
Write-Host " run the changed tool script with a safe smoke-test command"
}
if ($touchedCultureUpgradeConfig.Count -gt 0) {
Write-Host " Tools/CheckCultureUpgradeUnitConfig.ps1"
Write-Host " Tools/CheckCultureUpgradeUnitConfig.ps1 -CheckExport"
}
if ($touchedAunnTwinSkill.Count -gt 0) {
Write-Host " Tools/CheckAunnTwinAutoPetrify.ps1"
}
if ($touchedAunnPetrifiedDefenseAura.Count -gt 0) {
Write-Host " Tools/CheckAunnPetrifiedDefenseAura.ps1"
Write-Host " Tools/CheckAunnPetrifiedDefenseAura.ps1 -CheckExport"
}
if ($touchedSuikaFallingSplashAnimation.Count -gt 0) {
Write-Host " Tools/CheckSuikaFallingSplashAnimation.ps1"
}
if ($touchedSkillDataAssets.Count -gt 0) {
Write-Host " Tools/CheckSkillDataAssetsIntegrity.ps1"
Write-Host " Tools/CheckSkillDataAssetsIntegrity.ps1 -CheckExport"
}
if ($touchedUnityDataAssetReferences.Count -gt 0) {
Write-Host " Tools/CheckUnityAssetReferenceIntegrity.ps1"
}
if ($paths.Count -eq 0) {
Write-Host " none; working tree is clean"
}
if ($Strict -and $hasGuardrailIssue) {
throw "Git checkpoint guardrail failed. Remove protected generated/build artifacts or commit them only through an explicit workflow."
if ($Strict -and $hasBlockingIssue) {
throw "Git checkpoint guardrail failed. Remove build/package artifacts or large graphify cache files before committing."
}

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

View File

@ -0,0 +1,127 @@
fileFormatVersion: 2
guid: 80387df24a6831268cbb58d18b3bde45
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 2
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 512
resizeAlgorithm: 0
textureFormat: 25
textureCompression: 1
compressionQuality: 80
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 1
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Server
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID: 6e91a38e77ba04ef55c335e2ac536747
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

View File

@ -0,0 +1,127 @@
fileFormatVersion: 2
guid: 4392c1f95b2a98f849f70fc0247df3d4
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 2
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 512
resizeAlgorithm: 0
textureFormat: 25
textureCompression: 1
compressionQuality: 80
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 1
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Server
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID: d3aed48aaf92a5ff6b9f757796441d1b
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

View File

@ -0,0 +1,127 @@
fileFormatVersion: 2
guid: 78fd19cdb8ced0622dd8a53bbcbae046
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 2
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 512
resizeAlgorithm: 0
textureFormat: 25
textureCompression: 1
compressionQuality: 80
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 1
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Server
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID: 2c2112dc0ca46d7bcc71b9292c8d1fe2
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

View File

@ -0,0 +1,127 @@
fileFormatVersion: 2
guid: fffe60f909bda32ad648771698f9e45d
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 2
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 512
resizeAlgorithm: 0
textureFormat: 25
textureCompression: 1
compressionQuality: 80
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 1
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Server
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID: 9fc37f64ca1d74f0ee4b6c796716b2b6
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

View File

@ -0,0 +1,127 @@
fileFormatVersion: 2
guid: 50f7f398d30255be1623bb04ab1099f2
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 2
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 512
resizeAlgorithm: 0
textureFormat: 25
textureCompression: 1
compressionQuality: 80
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 1
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Server
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID: e6c816133f9c979b0ec2f6b3b06a28e0
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

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