Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions .github/workflows/codex-review-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
name: Codex Review Gate

on:
pull_request:
branches:
- "3.4.3"
types:
- opened
- synchronize
- reopened
- ready_for_review

permissions:
contents: read
issues: read
pull-requests: read

jobs:
codex-review-verdict:
name: Codex reviewer verdict
runs-on: ubuntu-24.04
steps:
- name: Require clean Codex review on current HEAD
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
CODEX_REVIEW_TIMEOUT_SECONDS: "1800"
CODEX_REVIEW_POLL_SECONDS: "30"
run: |
python3 <<'PY'
import json
import os
import re
import subprocess
import sys
import time

repo = os.environ["REPOSITORY"]
pr_number = os.environ["PR_NUMBER"]
head_sha = os.environ["HEAD_SHA"].lower()
timeout_seconds = int(os.environ.get("CODEX_REVIEW_TIMEOUT_SECONDS", "1800"))
poll_seconds = int(os.environ.get("CODEX_REVIEW_POLL_SECONDS", "30"))

codex_logins = {"chatgpt-codex-connector", "chatgpt-codex-connector[bot]"}
positive_markers = (
"didn't find any major issues",
"did not find any major issues",
"no major issues",
)
negative_markers = (
"automated review suggestions",
"p0 badge",
"p1 badge",
"p2 badge",
"p3 badge",
)

def gh_json(path):
output = subprocess.check_output(["gh", "api", path], text=True)
return json.loads(output)

def gh_json_pages(path):
page = 1
items = []
while True:
separator = "&" if "?" in path else "?"
page_items = gh_json(f"{path}{separator}per_page=100&page={page}")
if not isinstance(page_items, list):
raise TypeError(f"Expected paginated list from gh api {path}")
items.extend(page_items)
if len(page_items) < 100:
return items
page += 1

def reviewed_commits(body, fallback_commit=None):
commits = [
commit.lower()
for commit in re.findall(
r"Reviewed commit:[*\s]*`?([0-9a-fA-F]{7,40})`?",
body or "",
)
]
if fallback_commit:
commits.append(fallback_commit.lower())
return commits

def is_current_head(commit):
return bool(commit) and (head_sha.startswith(commit) or commit.startswith(head_sha))

def codex_items_for_current_head():
comments = gh_json_pages(f"repos/{repo}/issues/{pr_number}/comments")
reviews = gh_json_pages(f"repos/{repo}/pulls/{pr_number}/reviews")

items = []
for item in comments:
if item.get("user", {}).get("login") not in codex_logins:
continue
body = item.get("body") or ""
if any(is_current_head(commit) for commit in reviewed_commits(body)):
items.append((
"comment",
body,
item.get("html_url", ""),
item.get("created_at", ""),
))

for item in reviews:
if item.get("user", {}).get("login") not in codex_logins:
continue
body = item.get("body") or ""
fallback_commit = item.get("commit_id")
if any(
is_current_head(commit)
for commit in reviewed_commits(body, fallback_commit)
):
url = item.get("html_url") or item.get("_links", {}).get("html", {}).get("href", "")
items.append((
"review",
body,
url,
item.get("submitted_at") or item.get("updated_at") or "",
))

return items

def classify_codex_body(body):
lower_body = body.lower()
if any(marker in lower_body for marker in negative_markers):
return "fail"
if any(marker in lower_body for marker in positive_markers):
return "pass"
return None

def verdict():
items = codex_items_for_current_head()
classified = []

for kind, body, url, timestamp in items:
state = classify_codex_body(body)
if state:
classified.append((timestamp, state, kind, url))

if classified:
timestamp, state, kind, url = max(classified, key=lambda item: item[0])
return state, [(kind, url)]
return "wait", items

print(f"Waiting for clean Codex review on PR #{pr_number} at {head_sha[:10]}")
print("Comment '@codex review' on the PR if the review has not started.")
deadline = time.time() + timeout_seconds

while True:
state, items = verdict()
if state == "pass":
print("Found clean Codex review for current HEAD:")
for kind, url in items:
print(f"- {kind}: {url}")
sys.exit(0)

if state == "fail":
print("Codex left review feedback for current HEAD; address it before merge:")
for kind, url in items:
print(f"- {kind}: {url}")
sys.exit(1)

remaining = int(deadline - time.time())
if remaining <= 0:
print(
"Timed out waiting for a clean Codex review on the current HEAD. "
"Comment '@codex review' on the PR, then rerun this job after Codex responds."
)
sys.exit(1)

print(
f"No clean Codex verdict for {head_sha[:10]} yet; "
f"checking again in {poll_seconds}s ({remaining}s left)."
)
time.sleep(poll_seconds)
PY
9 changes: 9 additions & 0 deletions crates/wow-data/src/player_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,15 @@ impl PlayerStatsStore {
self.stats.get(&(race, class, level))
}

/// Build a store from known rows.
pub fn from_entries(
entries: impl IntoIterator<Item = ((u8, u8, u8), PlayerLevelStats)>,
) -> Self {
Self {
stats: entries.into_iter().collect(),
}
}

/// Number of entries loaded.
pub fn len(&self) -> usize {
self.stats.len()
Expand Down
10 changes: 10 additions & 0 deletions crates/wow-database/src/statements/character.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,9 @@ pub enum CharStatements {
/// UPDATE characters SET money = ? WHERE guid = ?
UPD_CHAR_MONEY,
/// C++ `CHAR_UPD_CHARACTER` persists these fields in the full save.
/// UPDATE characters SET power1 = ?, ..., power10 = ? WHERE guid = ?
UPD_CHAR_POWERS,
/// C++ `CHAR_UPD_CHARACTER` persists these fields in the full save.
/// UPDATE characters SET resettalents_cost = ?, resettalents_time = ? WHERE guid = ?
UPD_CHAR_TALENT_RESET_STATE,
/// UPDATE characters SET xp = ? WHERE guid = ?
Expand Down Expand Up @@ -2740,6 +2743,9 @@ impl StatementDef for CharStatements {
Self::UPD_CHAR_XP => "UPDATE characters SET xp = ? WHERE guid = ?",
Self::UPD_CHAR_LEVEL => "UPDATE characters SET level = ?, xp = ? WHERE guid = ?",
Self::UPD_CHAR_MONEY => "UPDATE characters SET money = ? WHERE guid = ?",
Self::UPD_CHAR_POWERS => {
"UPDATE characters SET power1 = ?, power2 = ?, power3 = ?, power4 = ?, power5 = ?, power6 = ?, power7 = ?, power8 = ?, power9 = ?, power10 = ? WHERE guid = ?"
}
Self::UPD_CHAR_TALENT_RESET_STATE => {
"UPDATE characters SET resettalents_cost = ?, resettalents_time = ? WHERE guid = ?"
}
Expand Down Expand Up @@ -4579,6 +4585,10 @@ mod tests {
CharStatements::UPD_CHAR_MONEY.sql(),
"UPDATE characters SET money = ? WHERE guid = ?"
);
assert_eq!(
CharStatements::UPD_CHAR_POWERS.sql(),
"UPDATE characters SET power1 = ?, power2 = ?, power3 = ?, power4 = ?, power5 = ?, power6 = ?, power7 = ?, power8 = ?, power9 = ?, power10 = ? WHERE guid = ?"
);
assert_eq!(
CharStatements::UPD_CHAR_DIFFICULTIES.sql(),
"UPDATE characters SET dungeonDifficulty = ?, raidDifficulty = ?, legacyRaidDifficulty = ? WHERE guid = ?"
Expand Down
100 changes: 94 additions & 6 deletions crates/wow-packet/src/packets/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1550,6 +1550,8 @@ pub struct PlayerCreateData {
pub base_armor: i32,
/// Max mana from level stats (for caster classes).
pub max_mana: i64,
/// Current primary power stored in `UnitData::Power[0]`.
pub current_power0: i32,
/// Melee attack power.
pub attack_power: i32,
/// Ranged attack power.
Expand Down Expand Up @@ -1659,13 +1661,13 @@ impl PlayerCreateData {
}
}

/// Get the power value for slot 0, using real mana for caster classes.
/// Get the max power value for slot 0, using real mana for caster classes.
///
/// - Warrior (1): rage = 1000 (stored as 10×)
/// - Rogue (4): energy = 100
/// - DK (6): runic power = 1000 (stored as 10×)
/// - All others: mana from `max_mana` field (loaded from player_levelstats)
fn power_for_slot0(&self) -> i32 {
fn max_power_for_slot0(&self) -> i32 {
match self.class {
1 => 1000, // Warrior: rage
4 => 100, // Rogue: energy
Expand All @@ -1674,6 +1676,19 @@ impl PlayerCreateData {
}
}

fn current_power_for_slot0(&self) -> i32 {
self.current_power0
.clamp(0, self.max_power_for_slot0().max(0))
}

fn base_mana_for_create_like_cpp(&self) -> i32 {
if power_type_for_class(self.class) == 0 {
self.max_mana.max(0).min(i64::from(i32::MAX)) as i32
} else {
0
}
}

/// Write the complete values block for CREATE (no change masks).
///
/// Format: `[u32 size][u8 flags][ObjectData][UnitData][PlayerData][ActivePlayerData?]`
Expand Down Expand Up @@ -1804,11 +1819,12 @@ impl PlayerCreateData {
}

// Power[10], MaxPower[10], ModPowerRegen[10]
let power0 = self.power_for_slot0();
let current_power0 = self.current_power_for_slot0();
let max_power0 = self.max_power_for_slot0();
for i in 0..10 {
if i == 0 {
buf.write_int32(power0);
buf.write_int32(power0);
buf.write_int32(current_power0);
buf.write_int32(max_power0);
} else {
buf.write_int32(0);
buf.write_int32(0);
Expand Down Expand Up @@ -1943,7 +1959,7 @@ impl PlayerCreateData {
}

// BaseMana — use real mana from stats store for caster classes
buf.write_int32(self.power_for_slot0());
buf.write_int32(self.base_mana_for_create_like_cpp());

// BaseHealth (Owner only)
if is_owner {
Expand Down Expand Up @@ -3745,6 +3761,12 @@ impl UpdateObject {
stats: combat.stats,
base_armor: combat.base_armor,
max_mana: combat.max_mana,
current_power0: match class {
1 => 1000,
4 => 100,
6 => 1000,
_ => combat.max_mana.max(0).min(i64::from(i32::MAX)) as i32,
},
attack_power: combat.attack_power,
ranged_attack_power: combat.ranged_attack_power,
min_damage: combat.min_damage,
Expand Down Expand Up @@ -3829,6 +3851,24 @@ impl UpdateObject {
}
}

/// Override `UnitData::Power[0]` for the self player create block.
///
/// C++ `Player::BuildValuesCreate` serializes the live current power and
/// max power separately. Login loads current `characters.power1`; callers
/// that do not have that DB value keep the default full-power create data.
pub fn set_player_current_power0_like_cpp(&mut self, current_power0: i32) {
for block in &mut self.blocks {
if let UpdateBlock::CreateObject {
create_data,
is_self: true,
..
} = block
{
create_data.current_power0 = current_power0;
}
}
}

/// Populate C++ `Player::m_actionButtons` for the self create block.
pub fn set_player_action_buttons_like_cpp(
&mut self,
Expand Down Expand Up @@ -10936,6 +10976,7 @@ mod tests {
stats: [0; 5],
base_armor: 0,
max_mana: 0,
current_power0: 1000,
attack_power: 0,
ranged_attack_power: 0,
min_damage: 1.0,
Expand Down Expand Up @@ -11266,6 +11307,53 @@ mod tests {
assert_eq!(create_data.farsight_object, ObjectGuid::EMPTY);
}

#[test]
fn create_player_self_current_power_can_use_saved_db_value_like_cpp() {
let guid = ObjectGuid::create_player(1, 42);
let pos = Position::new(0.0, 0.0, 0.0, 0.0);
let mut combat = PlayerCombatStats::default();
combat.max_mana = 1000;
let mut packet = UpdateObject::create_player(
guid,
1,
5,
0,
1,
49,
&pos,
0,
12,
true,
[(0, 0, 0); 19],
[ObjectGuid::EMPTY; 141],
combat,
Vec::new(),
0,
Vec::new(),
);

packet.set_player_current_power0_like_cpp(321);

let UpdateBlock::CreateObject { create_data, .. } = &packet.blocks[0] else {
panic!("create_player should emit one CreateObject block");
};
assert_eq!(
create_data.current_power_for_slot0(),
321,
"C++ login self UpdateObject serializes current UnitData::Power[0] from characters.power1"
);
assert_eq!(
create_data.max_power_for_slot0(),
1000,
"current power must not overwrite UnitData::MaxPower[0]"
);
assert_eq!(
create_data.base_mana_for_create_like_cpp(),
1000,
"mana percentage spell costs still use create/base mana"
);
}

#[test]
fn create_player_non_self() {
// Non-self player should be smaller (no ActivePlayerData)
Expand Down
Loading
Loading